Material You has these shapes. Rounded stars, organic cookie blobs, hearts, clovers. They're everywhere on new Android versions. Google uses them for profile pictures, FABs, widgets, transitions.
On the web, we get
border-radius.I spent the last while porting Android's entire
androidx.graphics.shapes library from Kotlin to TypeScript. The result is shape-morph, a library that gives you Material Design 3 shapes as SVG paths, CSS clip-paths, or React components. With morphing.Shape Morph Demo
0:00 / 0:00
CSS can't do this.
border-radius gives you circles and rounded rectangles. That's it. If you want a cookie-shaped blob or a clover, you're hand-authoring SVG paths or using an image.The shapes in Material You aren't just SVGs either. They're mathematically defined rounded polygons built from cubic Bezier curves. Every corner has a configurable radius and smoothing value. The geometry is precise enough to interpolate between any two shapes smoothly, frame by frame.
Android has had this since
androidx.graphics.shapes shipped. The web had nothing equivalent. So I built it.Every shape in the library is a
RoundedPolygon. Think of it as a polygon where each corner is replaced by one or more cubic Bezier curves, controlled by a radius and a smoothing parameter. A star with 5 points, 0.1 radius rounding, and 0.5 smoothing gives you a soft, organic starfish. Set smoothing to 0 and you get sharp circular arcs at the tips.The library ships 35 preset shapes:
You can also build your own:
ts
createStar, createPolygon, createRectangle, createCircle. Define your seed points, set your rounding, and the library handles the cubic curve generation.Jetpack Compose supports shape morphing.
Rendering a single shape is straightforward. The hard part is morphing between two shapes that have completely different structures. A Circle has 8 cubic curves. A Heart has 16. A Cookie9Sided has 36. You can't just lerp the control points because there's nothing to match them against.
Android's solution is a feature-matching algorithm. The
Morph class takes two RoundedPolygon shapes and:- Measures each shape's outline, mapping every cubic curve to a progress value along the perimeter
- Detects features like convex corners, concave corners, and flat edges
- Matches features between the two shapes by proximity, with constraints (convex corners won't match concave ones)
- Subdivides both shapes at the matched feature positions, so they end up with the same number of cubic segments
- Interpolates each pair of matched cubics at the requested progress
Result?
morph.asCubics(0.5) gives you the exact midpoint between any two shapes, with every curve smoothly blended. Progress 0 is the start shape. Progress 1 is the end shape. Anything in between is a valid, smooth, closed shape.This is why CSS
clip-path transitions work with this library. The toClipPathPolygon function samples a fixed number of points along each cubic, so both the start and end polygon strings have identical vertex counts. Pure CSS transitions handle the rest.The library outputs three ways.
SVG path:
ts
CSS clip-path (for transitions):
ts
Because both clip-path strings have the same number of polygon vertices, browsers can transition between them natively. No JavaScript animation loop needed. Just
transition: clip-path 300ms ease-in-out.React components:
tsx
useMorph runs a requestAnimationFrame loop with ease-in-out interpolation. Change the target progress and it smoothly animates from the current position. The returned clipPath string updates every frame.I'm using the package on this very site. The explore section buttons have a shape background behind each icon that morphs from a Square to a Cookie9Sided on hover, with a 90-degree clockwise rotation driven by the morph progress:
tsx
The
progress value from useMorph is the current animated value between 0 and 1, so piping it into a rotation transform syncs the shape morph and the spin perfectly. Hover in: clockwise. Hover out: counterclockwise. Both lerped.Porting from Kotlin wasn't a clean copy-paste. Android's version uses mutable state heavily, passing arrays by reference, modifying objects in place, and relying on Android-specific math utilities. TypeScript's semantics are different enough that I had to rethink chunks of it.
The hardest file was
morph.ts. The feature-matching algorithm has this DoubleMapper that maintains a bidirectional progress mapping between two polygon outlines. Getting the edge cases right (wrap-around at the polygon seam, degenerate features, single-point edges) took the most time.The whole package has basically zero dependencies. Tree-shakeable. React is an optional peer dependency for the
shape-morph/react entry point.35 shapes, feature-matched morphing, three output formats, and built in React integration.
Check out
shape-morph on GitHub.