Cubic Bézier Curves in Jetpack Compose
1. Overview
Want to make your dessert-themed app’s UI stand out? Custom shapes in Jetpack Compose can elevate your design, especially for showcasing treats like ice cream.
As developers, we often ask: “How can we craft smooth, curvy shapes for UI elements?” Cubic Bézier curves enable precise, elegant shapes for components like cards, aligning with your app’s brand identity.
In this article, we’ll walk through creating a custom-shaped card for an ice cream app using Jetpack Compose, featuring smooth Bézier curves to captivate users from the first glance.
2. What are Cubic Bézier Curves?
A cubic Bézier curve is a smooth, parametric curve defined by a start point, an end point, and two control points that shape its curvature, ideal for creating custom UI elements in Jetpack Compose.
3. Why Use Cubic Bézier Curves?
Cubic Bézier curves offer flexibility to create unique, brand-aligned shapes for your app’s UI, enabling:
- Smooth, custom shapes for cards and images.
- Precise clipping with elegant, curvy edges.
- Scalable designs using percentage-based coordinates.
4. Key Features Table
Feature | Description |
---|---|
Custom Card Shape | Fully curved card using cubic Bézier curves. |
Top-Curved Image | Image clipped with Bézier curves on the top edge. |
Flexible Design | Percentage-based coordinates for scalable shapes. |
5. Implementation Steps
This guide walks you through creating an ice cream app UI with a custom-shaped card using cubic Bézier curves.
Each step includes a simple code snippet and explanation to help beginners build the UI step by step, leading to the full implementation.
Learn more about this shape: Wikipedia
5.1 Define Full Curved Card Shape
Create a custom shape with cubic Bézier curves for all edges of a card, using percentage-based coordinates for flexibility.
class CubicBezierCardShape : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val path = Path().apply {
val width = size.width
val height = size.height
// Start at top-left curve
moveTo(x = width.times(.02f), y = height.times(.25f))
// Top-left curve control points
val topLeftControlPoint1 = Offset(width.times(.02f), height.times(.03f))
val topLeftControlPoint2 = Offset(width.times(.03f), height.times(.02f))
// Draw top-left curve
cubicTo(
x1 = topLeftControlPoint1.x,
y1 = topLeftControlPoint1.y,
x2 = topLeftControlPoint2.x,
y2 = topLeftControlPoint2.y,
x3 = width.times(.25f),
y3 = height.times(.02f)
)
// Top straight line
lineTo(x = width.times(.75f), y = height.times(.02f))
// Top-right curve control points
val topRightControlPoint1 = Offset(width.times(.97f), height.times(.02f))
val topRightControlPoint2 = Offset(width.times(.98f), height.times(.03f))
// Draw top-right curve
cubicTo(
x1 = topRightControlPoint1.x,
y1 = topRightControlPoint1.y,
x2 = topRightControlPoint2.x,
y2 = topRightControlPoint2.y,
x3 = width.times(.98f),
y3 = height.times(.25f)
)
// Right straight line
lineTo(x = width.times(.98f), y = height.times(.75f))
// Bottom-right curve control points
val bottomRightControlPoint1 = Offset(width.times(.97f), height.times(.98f))
val bottomRightControlPoint2 = Offset(width.times(.98f), height.times(.97f))
// Draw bottom-right curve
cubicTo(
x1 = bottomRightControlPoint1.x,
y1 = bottomRightControlPoint1.y,
x2 = bottomRightControlPoint2.x,
y2 = bottomRightControlPoint2.y,
x3 = width.times(.75f),
y3 = height.times(.98f)
)
// Bottom straight line
lineTo(x = width.times(.25f), y = height.times(.98f))
// Bottom-left curve control points
val bottomLeftControlPoint1 = Offset(width.times(.03f), height.times(.98f))
val bottomLeftControlPoint2 = Offset(width.times(.02f), height.times(.97f))
// Draw bottom-left curve
cubicTo(
x1 = bottomLeftControlPoint1.x,
y1 = bottomLeftControlPoint1.y,
x2 = bottomLeftControlPoint2.x,
y2 = bottomLeftControlPoint2.y,
x3 = width.times(.02f),
y3 = height.times(.75f)
)
// Close path
close()
}
return Outline.Generic(path)
}
}
Explanation:
The CubicBezierCardShape implements the Shape interface, using cubicTo to draw smooth curves for all four edges of a card.
It starts at a point (2% width, 25% height), uses two control points per curve to shape the path, and connects segments with straight lines, ensuring scalability with percentage-based coordinates.
UI design inspiration:
This curves originally developed by French engineer Pierre Bézier in the 1960s for designing car bodies.
Now a day's, Native platforms like iOS and popular apps like Swiggy use Bézier curves extensively to craft sleek, natural shapes that make UI elements feel polished and intuitive. These curves go beyond basic rounded corners, helping your app stand out with a unique and memorable brand feel.
5.2 Define Top-Curved Image Shape
Create a shape with cubic Bézier curves for the top edge and straight lines for the sides and bottom, ideal for clipping images.
class TopCubicBezierImageShape : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val path = Path().apply {
val width = size.width
val height = size.height
// Start at top-left curve
moveTo(x = width.times(.02f), y = height.times(.25f))
// Top-left curve control points
val topLeftControlPoint1 = Offset(width.times(.02f), height.times(.03f))
val topLeftControlPoint2 = Offset(width.times(.03f), height.times(.02f))
// Draw top-left curve
cubicTo(
x1 = topLeftControlPoint1.x,
y1 = topLeftControlPoint1.y,
x2 = topLeftControlPoint2.x,
y2 = topLeftControlPoint2.y,
x3 = width.times(.25f),
y3 = height.times(.02f)
)
// Top straight line
lineTo(x = width.times(.75f), y = height.times(.02f))
// Top-right curve control points
val topRightControlPoint1 = Offset(width.times(.97f), height.times(.02f))
val topRightControlPoint2 = Offset(width.times(.98f), height.times(.03f))
// Draw top-right curve
cubicTo(
x1 = topRightControlPoint1.x,
y1 = topRightControlPoint1.y,
x2 = topRightControlPoint2.x,
y2 = topRightControlPoint2.y,
x3 = width.times(.98f),
y3 = height.times(.25f)
)
// Straight right side
lineTo(x = width.times(.98f), y = height)
// Straight bottom
lineTo(x = width.times(.02f), y = height)
// Straight left side
lineTo(x = width.times(.02f), y = height.times(.25f))
// Close path
close()
}
return Outline.Generic(path)
}
}
Explanation:
The TopCubicBezierImageShape
defines a shape with smooth Bézier curves on the top edge, using two control points per curve, and straight lines for the sides and bottom to create a clean base for images, with percentage-based coordinates for scalability.
5.3 Create Ice Cream Screen
Build the main screen with a card using the custom shapes, an image, and text content, styled with dynamic theme colors.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IceCreamScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background) // Dynamic background
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = CubicBezierCardShape(),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant // Dynamic surface color
)
) {
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface) // Dynamic surface background
) {
// Display ice cream image
Image(
painter = painterResource(id = R.drawable.sample_image),
contentDescription = "Ice Cream Image",
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.clip(TopCubicBezierImageShape()),
contentScale = ContentScale.Crop
)
// Text content container
Column(
modifier = Modifier
.padding(36.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
// Ice cream title
Text(
text = "Thai Spring Lemon Ice Cream",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = MaterialTheme.colorScheme.onSurface // Dynamic text color
),
modifier = Modifier.align(Alignment.Start)
)
Spacer(modifier = Modifier.height(8.dp))
// Ice cream description
Text(
text = "Indulge in the tasty and sweet lemon flavor, with a refreshing green twist from Thai Spring.",
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant // Dynamic text color
),
modifier = Modifier.align(Alignment.Start)
)
}
}
}
}
}
Explanation: The IceCreamScreen
composable creates a Card
with CubicBezierCardShape
for a fully curved shape, clips an image with TopCubicBezierImageShape
for a curved top edge, and displays a bold title and description using dynamic theme colors for light/dark mode compatibility.
6. Understanding cubicTo() with Interactive Demo
To make cubicTo()
easier to understand, explore this interactive tool to visualize and manipulate Cubic Bézier curves: https://cubic-bezier.com/
7. Full Source Code
Below is the complete source code for the ice cream app UI, combining all steps into a single implementation, including preview composables for testing.
7.1 MainActivity.kt
package com.android.uix.new
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.android.uix.R
import com.android.uix.ui.theme.ComposeUIXTheme
// Custom shape for card with curved edges
class CubicBezierCardShape : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val path = Path().apply {
val width = size.width
val height = size.height
// Start at top-left curve
moveTo(x = width.times(.02f), y = height.times(.25f))
// Top-left curve control points
val topLeftControlPoint1 = Offset(width.times(.02f), height.times(.03f))
val topLeftControlPoint2 = Offset(width.times(.03f), height.times(.02f))
// Draw top-left curve
cubicTo(
x1 = topLeftControlPoint1.x,
y1 = topLeftControlPoint1.y,
x2 = topLeftControlPoint2.x,
y2 = topLeftControlPoint2.y,
x3 = width.times(.25f),
y3 = height.times(.02f)
)
// Top straight line
lineTo(x = width.times(.75f), y = height.times(.02f))
// Top-right curve control points
val topRightControlPoint1 = Offset(width.times(.97f), height.times(.02f))
val topRightControlPoint2 = Offset(width.times(.98f), height.times(.03f))
// Draw top-right curve
cubicTo(
x1 = topRightControlPoint1.x,
y1 = topRightControlPoint1.y,
x2 = topRightControlPoint2.x,
y2 = topRightControlPoint2.y,
x3 = width.times(.98f),
y3 = height.times(.25f)
)
// Right straight line
lineTo(x = width.times(.98f), y = height.times(.75f))
// Bottom-right curve control points
val bottomRightControlPoint1 = Offset(width.times(.97f), height.times(.98f))
val bottomRightControlPoint2 = Offset(width.times(.98f), height.times(.97f))
// Draw bottom-right curve
cubicTo(
x1 = bottomRightControlPoint1.x,
y1 = bottomRightControlPoint1.y,
x2 = bottomRightControlPoint2.x,
y2 = bottomRightControlPoint2.y,
x3 = width.times(.75f),
y3 = height.times(.98f)
)
// Bottom straight line
lineTo(x = width.times(.25f), y = height.times(.98f))
// Bottom-left curve control points
val bottomLeftControlPoint1 = Offset(width.times(.03f), height.times(.98f))
val bottomLeftControlPoint2 = Offset(width.times(.02f), height.times(.97f))
// Draw bottom-left curve
cubicTo(
x1 = bottomLeftControlPoint1.x,
y1 = bottomLeftControlPoint1.y,
x2 = bottomLeftControlPoint2.x,
y2 = bottomLeftControlPoint2.y,
x3 = width.times(.02f),
y3 = height.times(.75f)
)
// Close path
close()
}
return Outline.Generic(path)
}
}
// Custom shape for image with curved top and straight bottom
class TopCubicBezierImageShape : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val path = Path().apply {
val width = size.width
val height = size.height
// Start at top-left curve
moveTo(x = width.times(.02f), y = height.times(.25f))
// Top-left curve control points
val topLeftControlPoint1 = Offset(width.times(.02f), height.times(.03f))
val topLeftControlPoint2 = Offset(width.times(.03f), height.times(.02f))
// Draw top-left curve
cubicTo(
x1 = topLeftControlPoint1.x,
y1 = topLeftControlPoint1.y,
x2 = topLeftControlPoint2.x,
y2 = topLeftControlPoint2.y,
x3 = width.times(.25f),
y3 = height.times(.02f)
)
// Top straight line
lineTo(x = width.times(.75f), y = height.times(.02f))
// Top-right curve control points
val topRightControlPoint1 = Offset(width.times(.97f), height.times(.02f))
val topRightControlPoint2 = Offset(width.times(.98f), height.times(.03f))
// Draw top-right curve
cubicTo(
x1 = topRightControlPoint1.x,
y1 = topRightControlPoint1.y,
x2 = topRightControlPoint2.x,
y2 = topRightControlPoint2.y,
x3 = width.times(.98f),
y3 = height.times(.25f)
)
// Straight right side
lineTo(x = width.times(.98f), y = height)
// Straight bottom
lineTo(x = width.times(.02f), y = height)
// Straight left side
lineTo(x = width.times(.02f), y = height.times(.25f))
// Close path
close()
}
return Outline.Generic(path)
}
}
// Main composable for ice cream card
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IceCreamScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background) // Dynamic background
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = CubicBezierCardShape(),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant // Dynamic surface color
)
) {
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.surface) // Dynamic surface background
) {
// Display ice cream image
Image(
painter = painterResource(id = R.drawable.sample_image),
contentDescription = "Ice Cream Image",
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.clip(TopCubicBezierImageShape()),
contentScale = ContentScale.Crop
)
// Text content container
Column(
modifier = Modifier
.padding(36.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
// Ice cream title
Text(
text = "Thai Spring Lemon Ice Cream",
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = MaterialTheme.colorScheme.onSurface // Dynamic text color
),
modifier = Modifier.align(Alignment.Start)
)
Spacer(modifier = Modifier.height(8.dp))
// Ice cream description
Text(
text = "Indulge in the tasty and sweet lemon flavor, with a refreshing green twist from Thai Spring.",
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant // Dynamic text color
),
modifier = Modifier.align(Alignment.Start)
)
}
}
}
}
}
// Light theme preview
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO)
@Composable
fun IceCreamScreenLightPreview() {
ComposeUIXTheme(darkTheme = false) {
IceCreamScreen()
}
}
// Dark theme preview
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun IceCreamScreenDarkPreview() {
ComposeUIXTheme(darkTheme = true) {
IceCreamScreen()
}
}
8. Tips for Improvement
This implementation is a single-page demo for simplicity, but here are tips to enhance it for production:
- Separation of Concerns: Split shapes and composables into separate files (e.g.,
Shapes.kt
,IceCreamScreen.kt
) for better modularity and maintainability. - Dynamic Image Loading: Use a library like Coil to load images dynamically instead of the static
R.drawable.sample_image
. - Theme Integration: Extend
ComposeUIXTheme
with custom typography and shapes to reinforce the dessert app’s brand identity. - Reusable Shapes: Parameterize control points in
CubicBezierCardShape
andTopCubicBezierImageShape
to reuse across different components. - Organize Previews: Move preview composables to a separate file to keep the main code focused on production logic.
- Enhanced Theme Support: Test additional theme variations (e.g., high-contrast mode) to ensure accessibility.
9. Community Feedback
Do you have suggestions to improve this ice cream app UI or the Jetpack Compose code using cubic Bézier curves? Share your ideas, optimizations, or alternative approaches in the comments. We’ll update this guide with your feedback!