Examples

The icon grid shows what's there. This shows what it's for.

Each pattern brings its demo, its engine, and the code right next to it.

Which engine do I use?

Does the icon change shape?

morph

menu → X · play → pause · search → close

Two shapes, one control. The morph carries the change so the eye reads one button, not two.

See menu → X

Does the same icon react?

choreography

bell · heart · copy → check

Nothing to transition to. The shape stays and the movement carries the meaning.

See the bell

Is the duration unknown?

a looping choreography

loader-circle · refresh-cw

A morph has a start and an end. "Loading" has neither, so it needs an animation that cycles.

See three-state action

Mixing them isn't sloppiness. The three-state send button uses both: a looping choreography while it does not know when it will finish, and a morph the moment it does.See it in three-state action

Copy to clipboard#

morph

The case where morph clearly wins: copycheck confirms without shifting the layout or bolting on a toast. The button above actually copies — try it and paste.

Open in StackBlitz (opens in a new tab)
protected readonly copied = signal(false);

async copy() {
  try { await navigator.clipboard.writeText(text); } catch { return; }
  this.copied.set(true);
  setTimeout(() => this.copied.set(false), COPY_INTENT.autoReset);
}

<gf-icon-morph [intent]="COPY_INTENT" [active]="copied()" [animateAtRest]="true" [size]="18" />

spring takes a preset name. The full list, with the damping value behind each one, is in the API reference — it isn't copied here so the two can't drift apart. See the spring presets →

Aa

Theme switch#

morph

moonsun. The icon inherits the text color (stroke="currentColor"), so the theme never has to touch it directly: change the container's color and the icon follows.

Open in StackBlitz (opens in a new tab)
protected readonly light = signal(false);

<button [attr.aria-label]="light() ? 'Dark theme' : 'Light theme'">
  <gf-icon-morph [intent]="THEME_INTENT" [active]="!light()" [animateAtRest]="true" [size]="20" />
</button>

Three-state action#

morph + loop

Idle → sending → sent, and back again. sendcircle-check via morph; the spinner is a curated <gf-icon> on loop.

Open in StackBlitz (opens in a new tab)
@if (state() === 'sending') {
  <gf-icon [iconDef]="loaderCircleIcon" trigger="auto" [loop]="true" />
} @else {
  <gf-icon-morph [icon]="state() === 'sent' ? circleCheckIcon : sendIcon" />
}

Not a slip: they take different types. [iconDef] takes the icon's whole definition — its geometry plus its animation variants. [icon] takes the shape to morph INTO. See both in the API reference →

Reaction#

choreography

There's no morph here: the shape doesn't change, only the gesture. trigger="tap" replays the heart's curated choreography on every click, and the button supplies the color.

Open in StackBlitz (opens in a new tab)
<button [attr.aria-pressed]="liked()" (click)="toggle()">
  <gf-icon [iconDef]="heartIcon" [size]="18" trigger="tap" />
  {{ votes() }}
</button>

Player: play → pause#

morph

The same control, two shapes. aria-pressed carries the state; the morph only carries the change.

Open in StackBlitz (opens in a new tab)
readonly playing = signal(false);

<button [attr.aria-pressed]="playing()" (click)="playing.set(!playing())">
  <gf-icon-morph
    [intent]="PLAY_PAUSE_INTENT"
    [active]="playing()"
    [animateAtRest]="true"
    [size]="20"
  />
</button>

Accordion: the counterexample#

no engine — CSS

This one is not morph, and that is the point: the shape does not change, it rotates.

<!-- No morph: the shape does not change, it ROTATES. -->
<gf-icon [iconDef]="chevronDownIcon" [size]="16" trigger="manual" [class.open]="open()" />

gf-icon { transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); }
gf-icon.open { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) { gf-icon { transition: none; } }

Bell with notification#

choreography

Nothing to transition to: the shape stays the same and the movement carries the meaning.

Open in StackBlitz (opens in a new tab)
<!-- Same shape, movement with intent: choreography. -->
<gf-icon [iconDef]="bellIcon" [size]="20" trigger="hover" />

Search → close#

morph

The field expands and the icon announces what the button will do next.

Open in StackBlitz (opens in a new tab)
readonly open = signal(false);
readonly icon = computed(() => (this.open() ? xIcon : searchIcon));

<input [attr.tabindex]="open() ? null : -1" />
<button [attr.aria-expanded]="open()" (click)="toggle()">
  <gf-icon-morph [icon]="icon()" [size]="18" spring="snappy" />
</button>

Favorite#

morph

An intent already carries the pair and the spring decided — the input is [active], a boolean, not an icon. [animateAtRest] comes free: both sides get their own hover.

Open in StackBlitz (opens in a new tab)
readonly favorite = signal(true);

<!-- "active" es el lado -Off del intent (starOff): se invierte para que "favorite" siga en positivo. -->
<button [attr.aria-pressed]="favorite()" (click)="favorite.set(!favorite())">
  <gf-icon-morph [intent]="FAVORITE_INTENT" [active]="!favorite()" [animateAtRest]="true" [size]="20" />
</button>

Mute notifications#

morph

Same icon as the bell above, different mechanism: here a second shape genuinely exists (bellOffIcon), so what plays is a real morph, not a gesture on the same shape.

Open in StackBlitz (opens in a new tab)
readonly muted = signal(false);

<button [attr.aria-pressed]="muted()" (click)="muted.set(!muted())">
  <gf-icon-morph [intent]="NOTIFY_INTENT" [active]="muted()" [animateAtRest]="true" [size]="20" />
</button>

Pin message#

morph

[intent] with [active] inverted: the -Off side of the pair (mapPinOffIcon) is the one that's off, not the one that's marked — the demo's signal still reads positively.

Open in StackBlitz (opens in a new tab)
readonly pinned = signal(true);

<!-- Mismo motivo que en favoritos: "active" es mapPinOff, se invierte. -->
<button [attr.aria-pressed]="pinned()" (click)="pinned.set(!pinned())">
  <gf-icon-morph [intent]="PIN_INTENT" [active]="!pinned()" [animateAtRest]="true" [size]="18" />
</button>

Mute sound#

morph

Same pattern as above, for a media player: [active] in two positions, with sound waves and without.

Open in StackBlitz (opens in a new tab)
readonly muted = signal(false);

<button [attr.aria-pressed]="muted()" (click)="muted.set(!muted())">
  <gf-icon-morph [intent]="VOLUME_INTENT" [active]="muted()" [animateAtRest]="true" [size]="20" />
</button>

Remove like#

morph

Same heart as the reaction above, but this genuinely is a morph: heartOffIcon is a real shape (split by a slash), not a gesture on the same heart.

Open in StackBlitz (opens in a new tab)
readonly removed = signal(false);

<button [attr.aria-pressed]="removed()" (click)="removed.set(!removed())">
  <gf-icon-morph [intent]="LIKE_INTENT" [active]="removed()" [animateAtRest]="true" [size]="18" />
</button>

Show password#

morph

PASSWORD_INTENT already brings the eye and the crossed-out eye with their spring decided. Unlike favorite/pin, here active IS the state you see directly: visible, nothing inverted.

Open in StackBlitz (opens in a new tab)
readonly visible = signal(false);

<input [type]="visible() ? 'text' : 'password'" />
<button [attr.aria-pressed]="visible()" (click)="visible.set(!visible())">
  <gf-icon-morph
    [intent]="PASSWORD_INTENT"
    [active]="visible()"
    [animateAtRest]="true"
    [size]="18"
  />
</button>

Expand/collapse#

morph

The counterexample above rotates a chevron with CSS because there's nothing to transition to. EXPAND_COLLAPSE_INTENT is the other half: when two real shapes DO exist (chevron down, chevron up), a morph is also a valid choice.

Open in StackBlitz (opens in a new tab)
readonly open = signal(false);

<button [attr.aria-expanded]="open()" (click)="open.set(!open())">
  Details
  <gf-icon-morph
    [intent]="EXPAND_COLLAPSE_INTENT"
    [active]="open()"
    [animateAtRest]="true"
    [size]="16"
  />
</button>

Missing a pattern, or want to build your own?