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>The gesture of the site's support buttons: on hover, the resting icon slides up and out while another one enters from below, drawing itself. The motion is pure CSS; the drawing comes from <gf-icon trigger="group">, which listens to the hover of the button that contains it.
<a routerLink="/docs">
Get started
<app-icon-swap
[rest]="arrowRightIcon"
[enter]="rocketIcon"
>
<!-- The accent: a three-dot trail -->
<svg accent viewBox="0 0 28 28" fill="currentColor">
<circle cx="5.2" cy="23" r="1.25" />
<circle cx="3.1" cy="25.2" r="0.95" />
<circle cx="1.4" cy="27" r="0.7" />
</svg>
</app-icon-swap>
</a>Paste it into Claude, ChatGPT or Copilot. It names glyphflow's real API, so what you get back moves like this page does, not like a made-up version.
You are a senior Angular developer. Implement glyphflow's "icon swap": on a button or link, on hover (or keyboard focus) the resting icon slides up and out while another icon enters from below and draws itself, with an optional accent that arrives slightly later. Stack: Angular 20 or later, standalone components and signals, and the glyphflow library (npm i glyphflow), which animates Lucide icons with the Web Animations API. Do not add any other animation dependency. Requirements: 1. A standalone `IconSwap` component (selector `app-icon-swap`) with two signal inputs, `rest` and `enter`, typed `AnimatedIconDef` (imported from 'glyphflow'). 2. Template: two layers stacked in a fixed 16×16 px box. The resting one renders `<gf-icon [iconDef]="rest()" [size]="16" trigger="manual" />`; the entering one, `<gf-icon [iconDef]="enter()" [size]="16" trigger="group" />`. With `trigger="group"` the icon hooks onto the hover of the button that contains it and draws itself on entry: don't wire any events. 3. Inside the entering layer, a `<ng-content select="[accent]" />` slot wrapped in a 28×28 px box centered on the icon (`inset: -6px`). With no accent projected, nothing is painted. 4. All motion in CSS, no JavaScript and no `element.animate()`: with `:host-context(a:hover)`, `:host-context(button:hover)` and their `:focus-visible` equivalents, the resting layer goes to `translateY(-15px) scale(0.8)` at opacity 0, and the entering one comes from `translateY(15px) scale(0.8)` to `transform: none` at opacity 1. Put the `:hover` selectors inside `@media (hover: hover)`: on touch screens `:hover` sticks after a tap; keep the `:focus-visible` ones outside. 5. `transform` uses the library's spring: import `SPRING_SNAPPY` from 'glyphflow', pass it as a CSS variable from the host (`'[style.--swap-spring]': 'spring'`) and use `transition: transform 0.34s var(--swap-spring)`. `opacity` goes separately, with `0.16s ease`, because it is clamped between 0 and 1 and the spring's overshoot would be lost. 6. The accent enters 50 ms later, from `scale(0.6)` at opacity 0 to `scale(1)` at opacity 1, with the same durations. 7. Under `prefers-reduced-motion: reduce`, transition opacity only (0.12 s): the icon change is kept and the motion removed. 8. `aria-hidden="true"` on the host: the accessible name comes from the button's text. Give me the full component and an example on a "Get started" link that swaps `arrowRightIcon` for `rocketIcon`, with a three-dot trail behind the rocket as the accent.
Missing a pattern, or want to build your own?