Containment
Shape
All 35 Material 3 Expressive shapes (flower, cookies, bursts, clovers…) that morph into each other.
Flower
<Shape name="flower" />The same shapes as Tailwind masks: shape-cookie-9, shape-clover-4, shape-soft-burst.
The 35 shapes of Material 3 Expressive: cookies, clovers, bursts, the flower, puffy and pixel shapes, and more. Use them for avatars, image crops, decorative accents and loading states. Change name and the shape morphs into the new one on the motion scheme's spring.
Installation
npx @grenishrai/expressive add shapeThis copies shape.tsx into your components folder, along with what it needs from Expressive (material-shapes, motion, utils, styles).
Usage
import { Shape } from "@/components/expressive/shape";
<Shape name="flower" className="size-24 text-primary" />
// Morphs whenever the name changes
<Shape name={loading ? "soft-burst" : "cookie-9"} className="size-12 text-tertiary" />
// Clip anything to a shape with the Tailwind utilities
<img src={avatar} alt="" className="size-16 object-cover shape-cookie-12" />Props
Shape
| Prop | Type | Default | Description |
|---|---|---|---|
| name | MaterialShapeName | — | One of 35 shapes, e.g. flower, cookie-9, clover-4, soft-burst, heart. |
| label | string | — | Accessible name. Without it the shape is decorative (aria-hidden). |
| speed | "fast" | "default" | "slow" | "default" | Spring speed of the morph. |
| motion | "expressive" | "standard" | "fluid" | "none" | theme default | Motion scheme for this component's transitions. none turns animation off. |
| className | string | — | Size it with size-*; it fills with the text color (text-*). |
shape-* utilities
| Prop | Type | Default | Description |
|---|---|---|---|
| shape-{name} | Tailwind utility | — | Masks any element to a shape, e.g. shape-flower on an <img>. Only the ones you use end up in your CSS. |
| shapeMask(name) | (name) => string | — | The same mask as a CSS value, from tokens/material-shapes. |
Accessibility
- Shapes are decorative by default and hidden from screen readers.
- Give a meaningful shape a
label; it then renders asrole="img". - Morphs respect reduced motion: the shape switches instantly.
Notes
- Generated from Jetpack Compose's MaterialShapes and androidx.graphics.shapes (Apache-2.0), so the geometry matches Android.
- Morphing samples both outlines and interpolates between them. Sharp shapes (the pixel ones) land on their exact outline when the morph ends.
Source
This is the file the CLI writes into your project, shown with the default import paths. It's yours to change.
"use client";
import * as React from "react";
import { type MaterialShapeName, shapePaths } from "@/lib/expressive/tokens/material-shapes";
import { type SpringSpeed, getSpring } from "@/lib/expressive/tokens/motion";
import { type Point, align, lerpPoints, samplePath, smoothPath } from "@/lib/expressive/shape-morph";
import { type Spring, springSettled, stepSpring } from "@/lib/expressive/spring";
import { resolveScheme } from "@/lib/expressive/use-fluid";
import { useReducedMotion } from "@/lib/expressive/use-reduced-motion";
import type { MotionProp } from "@/lib/expressive/use-motion";
import { cn } from "@/lib/expressive/utils";
export interface ShapeProps extends Omit<React.ComponentProps<"svg">, "children"> {
/** One of the 35 Material shapes. Change it and the shape morphs to the new one. */
name: MaterialShapeName;
/** Accessible name. Without it the shape is decorative (hidden from assistive tech). */
label?: string;
motion?: MotionProp;
/** Spring speed of the morph. */
speed?: SpringSpeed;
/** Exact spring for the morph, overriding the scheme's (e.g. a loading indicator's). */
spring?: Spring;
}
const cache = new Map<MaterialShapeName, Point[] | null>();
const sampled = (name: MaterialShapeName) => {
if (!cache.has(name)) cache.set(name, samplePath(shapePaths[name]));
return cache.get(name)!;
};
/*
* A Material 3 Expressive shape, filled with the current text color:
* <Shape name="flower" className="size-24 text-primary" />
* Changing `name` morphs between shapes on the motion scheme's spatial spring
* (instantly with reduced motion or motion="none").
*/
export function Shape({ name, label, motion, speed = "default", spring: springOverride, className, ...props }: ShapeProps) {
const svgRef = React.useRef<SVGSVGElement>(null);
const pathRef = React.useRef<SVGPathElement>(null);
const reducedMotion = useReducedMotion();
const shown = React.useRef<{ name: MaterialShapeName; points: Point[] | null }>({ name, points: null });
const frame = React.useRef(0);
React.useEffect(() => () => cancelAnimationFrame(frame.current), []);
React.useLayoutEffect(() => {
const path = pathRef.current;
const previous = shown.current;
if (!path || previous.name === name) return;
cancelAnimationFrame(frame.current);
const scheme = resolveScheme(svgRef.current, motion);
// Start from whatever is on screen (mid-morph included), so morphs chain smoothly.
const from = previous.points ?? sampled(previous.name);
const target = sampled(name);
if (reducedMotion || scheme === "none" || !from || !target) {
shown.current = { name, points: null };
path.setAttribute("d", shapePaths[name]);
return;
}
const to = align(from, target);
const spring = springOverride ?? getSpring(scheme, "spatial", speed);
const state = { value: 0, velocity: 0 };
let last = performance.now();
shown.current = { name, points: from };
// React already wrote the target path; put the starting outline back before paint.
path.setAttribute("d", smoothPath(from));
const tick = (now: number) => {
stepSpring(state, 1, spring, Math.min((now - last) / 1000, 0.064));
last = now;
if (springSettled(state, 1, 1)) {
shown.current = { name, points: null };
path.setAttribute("d", shapePaths[name]); // land on the exact shape
return;
}
const points = lerpPoints(from, to, state.value);
shown.current = { name, points };
path.setAttribute("d", smoothPath(points));
frame.current = requestAnimationFrame(tick);
};
frame.current = requestAnimationFrame(tick);
// eslint-disable-next-line react-hooks/exhaustive-deps -- a new spring object each render shouldn't restart the morph
}, [name, motion, reducedMotion, speed]);
return (
<svg
ref={svgRef}
data-slot="shape"
viewBox="0 0 100 100"
fill="currentColor"
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn("inline-block size-12 shrink-0", className)}
{...props}
>
<path ref={pathRef} d={shapePaths[shown.current.points ? shown.current.name : name]} />
</svg>
);
}