Skip to content

Playing it

Spring from one drawing to the next when something changes: MorphIcon in React, mount in the page, motion for any other clock.

@cesarvenzac/morphv1.0.0

When something changes, a press, a state, a route, a drawing can spring to the next source rather than swap. Sent back mid-way, it turns back from where it is, at the speed it was going; sent somewhere new mid-way, it goes on from the frame it's at. MorphIcon and mount change at once under prefers-reduced-motion; with motion, call jump().

Slow
Real buttons. Press one twice quickly: it turns back from where it is.

In React

import { MorphIcon } from "@cesarvenzac/morph/react";
import { Pause, Play } from "lucide";

<button
  onClick={() => setPlaying(!playing)}
  aria-label={playing ? "Pause" : "Play"}
>
  <MorphIcon icon={playing ? Pause : Play} size={20} />
</button>;

MorphIcon takes an icon or any drawing as icon, and its size (24 unless said), strokeWidth (an icon's, 2 unless said), spring, way, extra and reducedMotion; any other prop goes on its <svg>. It's hidden from assistive tech unless it's given an aria-label or aria-labelledby: name the button it's in instead.

useMorphing(source, options) is its motion without the <svg>, { parts, viewBox, motion }, to draw your own way. They're client components, from React 18 on, which the package takes as an optional peer.

Without a framework

import { mount } from "@cesarvenzac/morph/dom";
import { Menu, X } from "lucide";

const icon = mount(button.querySelector("svg")!, Menu);
let open = false;

button.addEventListener("click", () => {
  open = !open;
  icon.to(open ? X : Menu);
});

mount(svg, source, options) makes an <svg> show source: to(next) springs there, set(next) shows it at once, stop() stops it, and motion says where it is. It draws with draw(svg, parts, viewBox), a <path> a part, made and taken away as their number changes.

Anywhere else

motion is the same spring, pure and immutable, given the time: for Svelte, Vue, a canvas or a game loop.

import { motion } from "@cesarvenzac/morph";

let icon = motion(Menu);
icon = icon.toward(X); // when it changes

let last = performance.now();
function tick(now: number) {
  icon = icon.step((now - last) / 1000);
  last = now;
  paint(icon.frame(), icon.viewBox); // yours
  if (!icon.settled) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

toward(next) heads for another source, step(seconds) moves it along its spring's exact path, the same at any frame rate and after a long pause, jump() puts it where it's headed, and frame() is what it draws now. It also says its morph, t, velocity, target, source and viewBox, and whether it's settled.

The spring

spring is { stiffness, damping }. The default, PRESS, is { stiffness: 260, damping: 32 }: as near critically damped as makes no overshoot, so an icon is nearly there in a third of a second, with no wobble. To keep its feel at another pace, divide the stiffness by the square of how many times slower and the damping by that number: four times slower is { stiffness: 16.25, damping: 8 }.

Driving it yourself

For a morph tied to a slider or a scroll position there's no spring to run: keep the morph, and draw the frame at the t you have.

const m = useMorph(Sun, Moon);
const t = useScrollProgress(ref); // 0 to 1, yours

return <MorphFrame morph={m} t={t} />;