Communication
Loading
Loading indicators: circular, wavy, the M3 Expressive shape morph, a spinning shape, bouncing dots and a pulse.
Loading…
wavy
Syncing photos
Thinking
circular · 20%
wavy · 20%
Loading indicators show that something is happening. Use morph, the M3 Expressive loading indicator, for short waits of a few seconds; circular or wavy when you can show progress; shape, dots or pulse for lighter, branded moments. For long, measurable tasks prefer a progress bar.
Installation
npx @grenishrai/expressive add loadingThis copies loading.tsx into your components folder, along with what it needs from Expressive (shape, motion, utils, styles).
Usage
import { Loading } from "@/components/expressive/loading";
<Loading variant="morph" /> // M3 Expressive shape morph
<Loading variant="morph" contained /> // on a filled container
<Loading variant="circular" label="Loading…" /> // with text
<Loading variant="wavy" value={progress} aria-label="Upload" /> // determinate
<Loading variant="shape" shape="flower" /> // one shape spinning
<Loading variant="dots" shape="cookie-4" label="Thinking" />Props
Loading
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "circular" | "wavy" | "morph" | "shape" | "dots" | "pulse" | "circular" | Indicator style. |
| label | ReactNode | — | Visible text; also the accessible name. |
| labelPosition | "end" | "bottom" | "end" | Where the label sits. |
| size | number | 48 | Indicator size in px. |
| value | number | null | null | circular and wavy only: 0–100 for determinate progress. |
| shape | MaterialShapeName | "flower" | shape, dots and pulse: which shape to draw. |
| shapes | MaterialShapeName[] | Compose sequence | morph: shapes to cycle through. Defaults to soft burst → 9-sided cookie → pentagon → pill → sunny → 4-sided cookie → oval. |
| contained | boolean | false | morph and shape: draw on a filled circular container. |
| aria-label | string | "Loading" | Accessible name when there's no label. |
Accessibility
- Renders
role="progressbar", named bylabel(oraria-label, defaulting to “Loading”). - Determinate indicators expose
aria-valuenowfrom 0 to 100; indeterminate ones expose no value. - Indicators keep moving under reduced motion, since the movement is the information, but morphs switch instantly and the morph indicator drops its quarter-turn jumps.
Notes
- The morph timing matches Compose's LoadingIndicator: a new shape every 650ms on a spring, a quarter turn per morph, and a full rotation every 4.7s.
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 { Shape } from "@/components/expressive/shape";
import type { MaterialShapeName } from "@/lib/expressive/tokens/material-shapes";
import { type Spring, springSettled, stepSpring } from "@/lib/expressive/spring";
import { useReducedMotion } from "@/lib/expressive/use-reduced-motion";
import { cn } from "@/lib/expressive/utils";
/*
* Loading indicators.
*
* circular the standard spinning arc (determinate with `value`)
* wavy the same ring drawn as a wave (M3 Expressive)
* morph the M3 Expressive loading indicator: morphs through a shape
* sequence while rotating, like Compose's LoadingIndicator
* shape one shape spinning and breathing
* dots three shapes bouncing in turn
* pulse one shape swelling and fading
*
* Indicators keep animating under reduced motion (the movement is the
* information), but large motions calm down: morphs switch instantly.
*/
export type LoadingVariant = "circular" | "wavy" | "morph" | "shape" | "dots" | "pulse";
export interface LoadingProps extends Omit<React.ComponentProps<"div">, "children"> {
variant?: LoadingVariant;
/** Visible text next to the indicator. Also its accessible name. */
label?: React.ReactNode;
labelPosition?: "end" | "bottom";
/** Indicator size in px. */
size?: number;
/** circular / wavy: progress 0–100, or null (default) while the duration is unknown. */
value?: number | null;
/** shape / dots / pulse: which shape to use. */
shape?: MaterialShapeName;
/** morph: the sequence to morph through. */
shapes?: MaterialShapeName[];
/** morph / shape: sit on a filled circular container. */
contained?: boolean;
}
/** Compose LoadingIndicatorDefaults.IndeterminateIndicatorPolygons. */
export const loadingSequence: MaterialShapeName[] = ["soft-burst", "cookie-9", "pentagon", "pill", "sunny", "cookie-4", "oval"];
// Compose LoadingIndicator timing.
const MORPH_INTERVAL = 650;
const GLOBAL_ROTATION_MS = 4666;
const morphSpring: Spring = { dampingRatio: 0.6, stiffness: 200 };
/** Active indicator (38dp) inside its 48dp container. */
const ACTIVE_SCALE = 38 / 48;
const spin = "[animation:ex-progress-spin_1.4s_linear_infinite]";
function Ring({ size, value, wavy }: { size: number; value: number | null; wavy: boolean }) {
const stroke = Math.max(3, size / 12);
const r = (size - stroke) / 2 - (wavy ? stroke * 0.6 : 0);
const c = size / 2;
const percent = value === null ? null : Math.min(Math.max(value, 0), 100);
// A circle, or a circle with a sine wave around it (one wave per ~10px of circumference).
const outline = React.useMemo(() => {
if (!wavy) return null;
const waves = Math.max(6, Math.round((2 * Math.PI * r) / 10));
const amplitude = stroke * 0.55;
const points: string[] = [];
for (let i = 0; i <= 240; i++) {
const a = (i / 240) * Math.PI * 2 - Math.PI / 2;
const rr = r + amplitude * Math.sin(waves * a);
points.push(`${(c + rr * Math.cos(a)).toFixed(2)} ${(c + rr * Math.sin(a)).toFixed(2)}`);
}
return `M ${points.join(" L ")} Z`;
}, [wavy, r, c, stroke]);
const common = { fill: "none", strokeWidth: stroke, strokeLinecap: "round" as const, pathLength: 200 };
const shapeEl = (props: React.SVGProps<SVGPathElement> & React.SVGProps<SVGCircleElement>) =>
outline ? <path d={outline} {...common} {...props} /> : <circle cx={c} cy={c} r={r} {...common} {...props} />;
return (
<svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} aria-hidden="true" className={cn("shrink-0", percent === null && spin)}>
{percent === null ? (
shapeEl({ className: "stroke-primary [animation:ex-progress-arc_1.4s_var(--ex-easing-emphasized)_infinite]" })
) : (
<g className="origin-center -rotate-90">
{shapeEl({ className: "stroke-secondary-container" })}
{shapeEl({
className: "stroke-primary transition-[stroke-dasharray] spring-spatial-slow",
strokeDasharray: `${percent * 2} 200`,
opacity: percent === 0 ? 0 : 1,
})}
</g>
)}
</svg>
);
}
/** Morphs through `shapes` every 650ms, a quarter turn per morph on top of a steady spin. */
function MorphIndicator({ shapes, size }: { shapes: MaterialShapeName[]; size: number }) {
const [index, setIndex] = React.useState(0);
const ref = React.useRef<HTMLDivElement>(null);
const turn = React.useRef({ value: 0, velocity: 0, target: 0 });
const reducedMotion = useReducedMotion();
React.useEffect(() => {
const id = setInterval(() => {
setIndex((i) => (i + 1) % shapes.length);
// Under reduced motion keep only the steady spin; no quarter-turn jumps.
if (!reducedMotion) turn.current.target += 90;
}, MORPH_INTERVAL);
return () => clearInterval(id);
}, [shapes.length, reducedMotion]);
React.useEffect(() => {
let frame = 0;
let last = performance.now();
const start = last;
const tick = (now: number) => {
const t = turn.current;
const state = { value: t.value, velocity: t.velocity };
stepSpring(state, t.target, morphSpring, Math.min((now - last) / 1000, 0.064));
t.value = reducedMotion || springSettled(state, t.target, 90) ? t.target : state.value;
t.velocity = state.velocity;
last = now;
const global = (((now - start) % GLOBAL_ROTATION_MS) / GLOBAL_ROTATION_MS) * 360;
if (ref.current) ref.current.style.transform = `rotate(${(global + t.value) % 360}deg)`;
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [reducedMotion]);
return (
<div ref={ref} className="flex items-center justify-center" style={{ width: size, height: size }}>
<Shape name={shapes[index]!} spring={morphSpring} style={{ width: size * ACTIVE_SCALE, height: size * ACTIVE_SCALE }} className="text-primary group-data-contained:text-on-primary-container" />
</div>
);
}
export function Loading({
variant = "circular",
label,
labelPosition = "end",
size = 48,
value = null,
shape = "flower",
shapes = loadingSequence,
contained = false,
className,
"aria-label": ariaLabel,
...props
}: LoadingProps) {
const labelId = React.useId();
const determinate = (variant === "circular" || variant === "wavy") && value !== null;
let indicator: React.ReactNode;
switch (variant) {
case "circular":
case "wavy":
indicator = <Ring size={size} value={value} wavy={variant === "wavy"} />;
break;
case "morph":
indicator = <MorphIndicator shapes={shapes} size={size} />;
break;
case "shape":
indicator = (
<div className={spin} style={{ width: size, height: size, animationDuration: "2.4s" }}>
<Shape
name={shape}
className="size-full text-primary [animation:ex-loading-breathe_1.2s_var(--ex-easing-emphasized)_infinite] group-data-contained:text-on-primary-container"
style={{ scale: ACTIVE_SCALE }}
/>
</div>
);
break;
case "dots":
indicator = (
<div className="flex items-end justify-center gap-[12%]" style={{ width: size, height: size * 0.6 }}>
{[0, 1, 2].map((i) => (
<Shape
key={i}
name={shape}
className="text-primary [animation:ex-loading-bounce_1.1s_var(--ex-easing-emphasized)_infinite]"
style={{ width: size * 0.24, height: size * 0.24, animationDelay: `${i * 0.14}s` }}
/>
))}
</div>
);
break;
case "pulse":
indicator = (
<div className="relative" style={{ width: size, height: size }}>
{[0, 1].map((i) => (
<Shape
key={i}
name={shape}
className="absolute inset-0 size-full text-primary [animation:ex-loading-pulse_1.6s_var(--ex-easing-emphasized-decelerate)_infinite]"
style={{ animationDelay: `${i * 0.8}s` }}
/>
))}
</div>
);
break;
}
if (contained && (variant === "morph" || variant === "shape")) {
indicator = (
<div className="grid place-items-center rounded-shape-full bg-primary-container" style={{ width: size, height: size }}>
{indicator}
</div>
);
}
return (
<div
role="progressbar"
aria-label={label ? undefined : (ariaLabel ?? "Loading")}
aria-labelledby={label ? labelId : undefined}
aria-valuemin={determinate ? 0 : undefined}
aria-valuemax={determinate ? 100 : undefined}
aria-valuenow={determinate ? Math.round(value!) : undefined}
data-slot="loading"
data-variant={variant}
data-contained={contained ? "" : undefined}
className={cn(
"group inline-flex items-center text-on-surface-variant",
labelPosition === "bottom" ? "flex-col gap-3" : "gap-3",
className,
)}
{...props}
>
{indicator}
{label && (
<span id={labelId} className="text-body-medium">
{label}
</span>
)}
</div>
);
}