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 → XThe 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.
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 → Xchoreography
bell · heart · copy → check
Nothing to transition to. The shape stays and the movement carries the meaning.
See the bella 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 actionMixing 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
The case where morph clearly wins: copy → check confirms without shifting the layout or bolting on a toast. The button above actually copies — try it and paste.
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 →
moon ↔ sun. 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.
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>Idle → sending → sent, and back again. send → circle-check via morph; the spinner is a curated <gf-icon> on loop.
@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 →
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.
<button [attr.aria-pressed]="liked()" (click)="toggle()">
<gf-icon [iconDef]="heartIcon" [size]="18" trigger="tap" />
{{ votes() }}
</button>Two different shapes for one control. The icon says what the button does now, not what it is.
readonly open = signal(false);
<button [attr.aria-expanded]="open()" aria-controls="menu" (click)="open.set(!open())">
<gf-icon-morph [intent]="MENU_CLOSE_INTENT" [active]="open()" [animateAtRest]="true" [size]="20" />
</button>The same control, two shapes. aria-pressed carries the state; the morph only carries the change.
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>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; } }Nothing to transition to: the shape stays the same and the movement carries the meaning.
<!-- Same shape, movement with intent: choreography. -->
<gf-icon [iconDef]="bellIcon" [size]="20" trigger="hover" />The field expands and the icon announces what the button will do next.
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>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.
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>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.
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>[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.
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>Same pattern as above, for a media player: [active] in two positions, with sound waves and without.
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>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.
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>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.
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>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.
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?