Morphing Icons
Build icons that transform through actual shape transformation, not crossfades. Any icon can morph into any other because they share the same underlying 3-line structure.
Core Concept
Every icon is composed of exactly three SVG lines. Icons that need fewer lines collapse the extras to invisible center points. This constraint enables seamless morphing between any two icons.
Architecture
1. Line Definition
Each line has coordinates and optional opacity:
ts
1interface IconLine {
2 x1: number;
3 y1: number;
4 x2: number;
5 y2: number;
6 opacity?: number;
7}
2. Collapsed Lines
Icons needing fewer than 3 lines use collapsed lines—zero-length lines at the center:
ts
1const CENTER = 7; // Center of 14x14 viewbox
2
3const collapsed: IconLine = {
4 x1: CENTER,
5 y1: CENTER,
6 x2: CENTER,
7 y2: CENTER,
8 opacity: 0,
9};
3. Icon Definition
Each icon has exactly 3 lines, optional rotation, and optional group:
ts
1interface IconDefinition {
2 lines: [IconLine, IconLine, IconLine];
3 rotation?: number;
4 group?: string;
5}
4. Rotation Groups
Icons sharing a group animate rotation when transitioning between them. Icons without matching groups jump to the new rotation instantly:
ts
1// These rotate smoothly between each other
2{ lines: plusLines, rotation: 0, group: "plus-cross" } // plus
3{ lines: plusLines, rotation: 45, group: "plus-cross" } // cross
4
5// These rotate smoothly between each other
6{ lines: arrowLines, rotation: 0, group: "arrow" } // arrow-right
7{ lines: arrowLines, rotation: 90, group: "arrow" } // arrow-down
8{ lines: arrowLines, rotation: 180, group: "arrow" } // arrow-left
9{ lines: arrowLines, rotation: -90, group: "arrow" } // arrow-up
Implementation Rules
morphing-three-lines
Every icon MUST use exactly 3 lines. No more, no fewer.
Fail:
ts
1const checkIcon = {
2 lines: [
3 { x1: 2, y1: 7.5, x2: 5.5, y2: 11 },
4 { x1: 5.5, y1: 11, x2: 12, y2: 3 },
5 ], // Only 2 lines
6};
Pass:
ts
1const checkIcon = {
2 lines: [
3 { x1: 2, y1: 7.5, x2: 5.5, y2: 11 },
4 { x1: 5.5, y1: 11, x2: 12, y2: 3 },
5 collapsed, // Third line collapsed
6 ],
7};
morphing-use-collapsed
Unused lines must use the collapsed constant, not omission or null.
Fail:
ts
1const minusIcon = {
2 lines: [
3 { x1: 2, y1: 7, x2: 12, y2: 7 },
4 null,
5 null,
6 ],
7};
Pass:
ts
1const minusIcon = {
2 lines: [
3 { x1: 2, y1: 7, x2: 12, y2: 7 },
4 collapsed,
5 collapsed,
6 ],
7};
morphing-consistent-viewbox
All icons must use the same viewBox (14x14 recommended).
Fail:
ts
1// Mixing viewbox scales
2const icon1 = { lines: [{ x1: 2, y1: 7, x2: 12, y2: 7 }, ...] }; // 14x14
3const icon2 = { lines: [{ x1: 4, y1: 14, x2: 24, y2: 14 }, ...] }; // 28x28
Pass:
ts
1const VIEWBOX_SIZE = 14;
2const CENTER = 7;
3// All coordinates within 0-14 range
morphing-group-variants
Icons that are rotational variants MUST share the same group and base lines.
Fail:
ts
1// Different line definitions for arrows
2const arrowRight = { lines: [{ x1: 2, y1: 7, x2: 12, y2: 7 }, ...] };
3const arrowDown = { lines: [{ x1: 7, y1: 2, x2: 7, y2: 12 }, ...] }; // Different!
Pass:
ts
1const arrowLines: [IconLine, IconLine, IconLine] = [
2 { x1: 2, y1: 7, x2: 12, y2: 7 },
3 { x1: 7.5, y1: 2.5, x2: 12, y2: 7 },
4 { x1: 7.5, y1: 11.5, x2: 12, y2: 7 },
5];
6
7const icons = {
8 "arrow-right": { lines: arrowLines, rotation: 0, group: "arrow" },
9 "arrow-down": { lines: arrowLines, rotation: 90, group: "arrow" },
10 "arrow-left": { lines: arrowLines, rotation: 180, group: "arrow" },
11 "arrow-up": { lines: arrowLines, rotation: -90, group: "arrow" },
12};
morphing-spring-rotation
Rotation between grouped icons should use spring physics for natural motion.
Fail:
tsx
1<motion.g animate={{ rotate: rotation }} transition={{ duration: 0.3 }} />
Pass:
tsx
1const rotation = useSpring(definition.rotation ?? 0, activeTransition);
2
3<motion.g style={{ rotate: rotation, transformOrigin: "center" }} />
morphing-reduced-motion
Respect prefers-reduced-motion by disabling animations.
Fail:
tsx
1function MorphingIcon({ icon }: Props) {
2 return <motion.line animate={...} transition={{ duration: 0.4 }} />;
3}
Pass:
tsx
1function MorphingIcon({ icon }: Props) {
2 const reducedMotion = useReducedMotion() ?? false;
3 const activeTransition = reducedMotion ? { duration: 0 } : transition;
4
5 return <motion.line animate={...} transition={activeTransition} />;
6}
morphing-jump-non-grouped
When transitioning between icons NOT in the same group, rotation should jump instantly.
Fail:
tsx
1// Always animating rotation regardless of group
2useEffect(() => {
3 rotation.set(definition.rotation ?? 0);
4}, [definition]);
Pass:
tsx
1useEffect(() => {
2 if (shouldRotate) {
3 rotation.set(definition.rotation ?? 0); // Animate
4 } else {
5 rotation.jump(definition.rotation ?? 0); // Instant
6 }
7}, [definition, shouldRotate]);
morphing-strokelinecap-round
Lines should use strokeLinecap="round" for polished endpoints.
Fail:
tsx
1<motion.line strokeLinecap="butt" />
Pass:
tsx
1<motion.line strokeLinecap="round" />
morphing-aria-hidden
Icon SVGs should be aria-hidden since they're decorative.
Fail:
tsx
1<svg width={size} height={size}>...</svg>
Pass:
tsx
1<svg width={size} height={size} aria-hidden="true">...</svg>
Common Icon Patterns
Two-Line Icons (check, minus, equals, chevron)
Use one or two collapsed lines:
ts
1const check = {
2 lines: [
3 { x1: 2, y1: 7.5, x2: 5.5, y2: 11 },
4 { x1: 5.5, y1: 11, x2: 12, y2: 3 },
5 collapsed,
6 ],
7};
Use all three lines:
ts
1const menu = {
2 lines: [
3 { x1: 2, y1: 3.5, x2: 12, y2: 3.5 },
4 { x1: 2, y1: 7, x2: 12, y2: 7 },
5 { x1: 2, y1: 10.5, x2: 12, y2: 10.5 },
6 ],
7};
Point Icons (more, grip)
Use zero-length lines as dots:
ts
1const more = {
2 lines: [
3 { x1: 3, y1: 7, x2: 3, y2: 7 },
4 { x1: 7, y1: 7, x2: 7, y2: 7 },
5 { x1: 11, y1: 7, x2: 11, y2: 7 },
6 ],
7};
Recommended Transition
Use exponential ease-out for smooth morphing:
ts
1const defaultTransition: Transition = {
2 ease: [0.19, 1, 0.22, 1],
3 duration: 0.4,
4};
When auditing morphing icon implementations, output findings as:
file:line - [rule-id] description of issue
Example:
components/icon/index.tsx:45 - [morphing-three-lines] Icon "check" has only 2 lines, needs collapsed third
components/icon/index.tsx:78 - [morphing-group-variants] arrow-down uses different line definitions than arrow-right
Summary Table
After findings, output a summary:
| Rule | Count | Severity |
|---|
morphing-three-lines | 2 | HIGH |
morphing-group-variants | 1 | HIGH |
morphing-reduced-motion | 1 | MEDIUM |
References