Expressive

Foundations

Motion

Motion in M3 Expressive is physics: springs instead of fixed durations. Expressive keeps every spring in one file, motion.ts, and runs them on plain CSS unless you choose an animation library.

Springs

A spring is described by its stiffness and damping ratio. Two kinds cover everything: spatial springs move things (position, size, shape) and may overshoot; effects springs change color and opacity and never overshoot. Each comes in fast, default and slow.

Scheme: expressiveChange it with Customize → Motion.

spatial
fast
360ms
default
435ms
slow
600ms
effects
fast
150ms
default
231ms
slow
327ms

Schemes

  • expressive (default): bouncier spatial springs for a lively feel.
  • standard: more damped, for calmer, utilitarian products.
  • fluid: soft and alive. Shapes drift, stretch and wobble like liquid. Needs the Motion or GSAP engine; see Fluid below.

Set the default in your theme (createTheme({ motion: { scheme: "standard" } })), or per component with the motion prop:

<Button motion="standard">Save</Button>
<Dialog>…<DialogContent motion="expressive">…</DialogContent></Dialog>
<Switch motion="none" />

Fluid

Fluid motion treats elements like droplets instead of rigid boxes. It's built from four behaviors, all driven by real spring physics and the element's velocity every frame:

  • Stretch with speed. A moving element elongates along its direction of travel and thins across it, keeping its area, then wobbles back into shape as it settles. It also leans a few degrees into horizontal motion. The switch handle does this.
  • Press jelly. Buttons, icon buttons, FABs and chips squash when pressed and spring back with a fading wide-tall wobble.
  • Touch response. Moving the pointer across a control gives it a small squash, in proportion to how fast you crossed it.
  • Gooey arrivals. Checkboxes, radios and navigation indicators swell in with a jelly settle; dialogs swell in from below; ripples spread as irregular blobs that relax into circles.
expressive
standard
fluid · motion

Momentum carries over: flip a switch back mid-motion and the handle turns around smoothly instead of restarting.

Requirements

CSS transitions can't read velocity, so fluid needs the Motion or GSAP engine. With the CSS engine, fluid falls back to expressive and logs a warning once. The CLI won't combine them:

npx @grenishrai/expressive init --scheme fluid            # picks the Motion engine
npx @grenishrai/expressive theme --scheme fluid --motion gsap

Or opt in per component with motion="fluid". Colors and fades still use CSS transitions, timed by the fluid springs.

Tuning

createTheme({
  motion: {
    scheme: "fluid",
    fluid: {
      viscosity: 0.045, // stretch per (size / second) of speed
      maxStretch: 0.3,  // up to 30% longer (and ~23% thinner)
      squash: 0.07,     // press squash
      wobble: 0.32,     // damping of the jelly wobble: lower = wobblier
    },
  },
});

In your own components

The same behaviors are hooks. They do nothing unless the element's scheme is fluid and the engine supports it:

import { useFluidLayout, useFluidPop, useFluidPress } from "@/lib/expressive/use-fluid";

function Tab({ selected }: { selected: boolean }) {
  const ref = useRef<HTMLButtonElement>(null);
  useFluidPress(ref);                                   // squash on press
  useFluidPop(ref, { attributes: ["data-selected"] });  // jelly when it becomes selected
  return <button ref={ref} data-selected={selected ? "" : undefined} className="fluid:transition-colors" />;
}

function Indicator({ index }: { index: number }) {
  const ref = useRef<HTMLSpanElement>(null);
  useFluidLayout(ref, { attributes: ["data-index"] });  // drift + stretch on layout change
  return <span ref={ref} data-index={index} style={{ left: index * 96 }} className="absolute fluid:transition-none" />;
}

While fluid is active the element gets data-fluid. Use the fluid: variant to switch off CSS transitions of whatever the hooks animate, as above, so the two don't fight.

How it runs

Most animation in Expressive is state-driven: a corner morphing on press, a switch handle sliding, a dialog opening. These are CSS transitions whose easing is a linear() curve sampled from the spring, so they cost no JavaScript and work with server rendering. Use them in your own code with the spring utilities:

<div className="transition-transform spring-spatial-fast hover:scale-105" />
<div className="transition-opacity spring-effects-default" />

Imperative animation (the ripple, or anything you run with useMotion) goes through an engine:

import { useMotion } from "@/lib/expressive/use-motion";

function Pop() {
  const ref = useRef<HTMLDivElement>(null);
  const { animate } = useMotion();
  const pop = () =>
    animate(ref.current, { transform: ["scale(0.8)", "scale(1)"] }, { type: "spatial", speed: "fast" });
  return <div ref={ref} onClick={pop} />;
}

Engines

tokens/motion.ts picks the engine with a single export. Switch it by changing that line (the CLI does it for you with --motion):

lib/expressive/tokens/motion.ts
export { adapter } from "@/lib/expressive/adapters/css";     // default, no dependency
// export { adapter } from "@/lib/expressive/adapters/motion"; // Motion (formerly Framer Motion)
// export { adapter } from "@/lib/expressive/adapters/gsap";   // GSAP
// export { adapter } from "./my-motion";                       // your own

An engine is one function. Write your own by implementing the adapter contract:

lib/expressive/tokens/my-motion.ts
import type { MotionAdapter } from "@/lib/expressive/adapters/types";
import { springDuration, springToLinear } from "@/lib/expressive/spring";

export const adapter: MotionAdapter = {
  name: "my-engine",
  animate(element, keyframes, { spring, delay = 0 }) {
    const animation = element.animate(keyframes as PropertyIndexedKeyframes, {
      duration: springDuration(spring),
      easing: springToLinear(spring),
      delay,
      fill: "forwards",
    });
    return { finished: animation.finished.then(() => {}), cancel: () => animation.cancel() };
  },
};

Reduced motion

When the operating system asks for reduced motion, every spring duration becomes 0, useMotion jumps straight to the final state, and fluid deformation turns off. You don't need to handle it yourself. motion="none" does the same for one component.