Ejemplos

La cuadrícula de iconos enseña qué hay. Esto enseña para qué sirve.

Cada patrón trae su demo, su motor y el código al lado.

¿Qué motor uso?

¿El icono cambia de forma?

morph

menú → X · play → pausa · buscar → cerrar

Dos formas, un control. El morph lleva el cambio para que el ojo lea un botón, no dos.

Ver menú → X

¿El mismo icono reacciona?

coreografía

campana · corazón · copiar → palomita

No hay a qué transicionar. La forma se queda y el movimiento lleva el significado.

Ver la campana

¿La duración es desconocida?

una coreografía en bucle

loader-circle · refresh-cw

Un morph tiene principio y fin. «Cargando» no tiene ninguno, así que necesita una animación que cicle.

Ver la acción de tres estados

Mezclarlos no es descuido. El botón de enviar usa los dos: una coreografía en bucle mientras no sabe cuándo va a terminar, y un morph en el momento en que ya lo sabe.Verlo en la acción de tres estados

Copiar al portapapeles#

morph

El caso donde el morph gana claro: copy → check confirma sin mover el layout ni meter un toast. El botón de arriba copia de verdad — pruébalo y pega.

Abrir en StackBlitz (se abre en una pestaña nueva)
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 recibe el nombre de un preset. La lista completa, con el amortiguamiento de cada uno, está en la referencia de API — no se copia aquí para que las dos no puedan separarse. Ver los presets de spring →

Aa

Cambio de tema#

morph

moon ↔ sun. El icono hereda el color del texto (stroke="currentColor"), así que el tema no necesita tocarlo: cambia el color del contenedor y el icono va detrás.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Acción con tres estados#

morph + bucle

Reposo → enviando → enviado, y de regreso. send → circle-check por morph; el spinner es un <gf-icon> curado en loop.

Abrir en StackBlitz (se abre en una pestaña nueva)
@if (state() === 'sending') {
  <gf-icon [iconDef]="loaderCircleIcon" trigger="auto" [loop]="true" />
} @else {
  <gf-icon-morph [icon]="state() === 'sent' ? circleCheckIcon : sendIcon" />
}

No es un descuido: reciben tipos distintos. [iconDef] recibe la definición completa del icono — su geometría más sus variantes de animación. [icon] recibe la forma HACIA la que transforma. Ver los dos en la referencia de API →

Reacción#

coreografía

Aquí no hay morph: la forma no cambia, solo el gesto. trigger="tap" reproduce la coreografía curada del corazón en cada click, y el color lo pone el botón.

Abrir en StackBlitz (se abre en una pestaña nueva)
<button [attr.aria-pressed]="liked()" (click)="toggle()">
  <gf-icon [iconDef]="heartIcon" [size]="18" trigger="tap" />
  {{ votes() }}
</button>

Reproductor: play → pausa#

morph

El mismo control, dos formas. El estado lo lleva aria-pressed; el morph solo lleva el cambio.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Acordeón: el contraejemplo#

sin motor — CSS

Este no es morph, y ese es el punto: la forma no cambia, gira.

<!-- 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; } }

Campana con notificación#

coreografía

No hay a qué transicionar: la forma es la misma y el movimiento es el que lleva el significado.

Abrir en StackBlitz (se abre en una pestaña nueva)
<!-- Same shape, movement with intent: choreography. -->
<gf-icon [iconDef]="bellIcon" [size]="20" trigger="hover" />

Buscar → cerrar#

morph

El campo se expande y el icono anuncia qué hará el botón a continuación.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Favorito#

morph

Un intent ya trae el par de figuras y el resorte decididos — el input es [active], un booleano, no un icono. [animateAtRest] de regalo: los dos lados tienen su propio hover.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Silenciar notificaciones#

morph

Mismo icono que la campana de arriba, mecanismo distinto: aquí sí existe una segunda forma (bellOffIcon), así que lo que se ve es un morph real, no un gesto sobre la misma figura.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Fijar mensaje#

morph

[intent] con [active] invertido: el lado -Off del par (mapPinOffIcon) es el que se apaga, no el que se marca — la señal del demo sigue leyendo en positivo.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Silenciar sonido#

morph

Igual patrón que arriba, para un reproductor: [active] en dos posiciones, con ondas de sonido y sin ellas.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Quitar like#

morph

Mismo corazón que la reacción de arriba, pero esto sí es un morph: heartOffIcon es una forma real (partida por una raya), no un gesto sobre el mismo corazón.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Mostrar contraseña#

morph

PASSWORD_INTENT ya trae el ojo y el ojo tachado con su resorte decidido. A diferencia de favoritos/fijar, aquí active SÍ es el estado que se ve tal cual: a la vista, sin invertir nada.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Desplegar/plegar#

morph

El contraejemplo de arriba gira un chevron con CSS porque no hay a qué transicionar. EXPAND_COLLAPSE_INTENT es la otra mitad: cuando SÍ hay dos formas reales (chevron abajo, chevron arriba), un morph también es una opción válida.

Abrir en StackBlitz (se abre en una pestaña nueva)
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>

Relevo de iconos#

coreografía

El gesto de los botones de apoyo del sitio: al pasar el puntero, el icono de reposo sale hacia arriba y entra otro desde abajo, dibujándose. El movimiento es CSS puro; el dibujo lo pone <gf-icon trigger="group">, que se cuelga del hover del botón que lo contiene.

Abrir en StackBlitz (se abre en una pestaña nueva)
<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>

Prompt para tu IA

Pégalo en Claude, ChatGPT o Copilot. Nombra la API real de glyphflow, así que lo que te devuelva se mueve igual que esta página, no como una versión inventada.

Eres un desarrollador senior de Angular. Implementa el «relevo de iconos» de glyphflow: en un botón o enlace, al pasar el puntero (o al enfocarlo con teclado) el icono de reposo sale hacia arriba y entra desde abajo otro icono que se dibuja solo, con un acento opcional que llega un poco después.

Stack: Angular 20 o superior, componentes standalone y signals, y la librería glyphflow (npm i glyphflow), que anima iconos de Lucide con la Web Animations API. No agregues ninguna otra dependencia de animación.

Requisitos:
1. Un componente standalone `IconSwap` (selector `app-icon-swap`) con dos inputs por señal, `rest` y `enter`, de tipo `AnimatedIconDef` (importado de 'glyphflow').
2. Plantilla: dos capas apiladas en una caja fija de 16×16 px. La de reposo pinta `<gf-icon [iconDef]="rest()" [size]="16" trigger="manual" />`; la de entrada, `<gf-icon [iconDef]="enter()" [size]="16" trigger="group" />`. Con `trigger="group"` el icono se cuelga del hover del botón que lo contiene y se dibuja al entrar: no cablees eventos.
3. Dentro de la capa de entrada, un hueco `<ng-content select="[accent]" />` envuelto en una caja de 28×28 px centrada sobre el icono (`inset: -6px`). Sin acento proyectado, no se pinta nada.
4. Todo el movimiento en CSS, sin JavaScript ni `element.animate()`: con `:host-context(a:hover)`, `:host-context(button:hover)` y sus equivalentes con `:focus-visible`, la capa de reposo va a `translateY(-15px) scale(0.8)` con opacidad 0, y la de entrada llega desde `translateY(15px) scale(0.8)` a `transform: none` con opacidad 1. Los selectores con `:hover` van dentro de `@media (hover: hover)`: en táctil el `:hover` se queda pegado después del tap; los de `:focus-visible` van fuera.
5. `transform` con el resorte de la librería: importa `SPRING_SNAPPY` de 'glyphflow', pásalo como variable CSS desde el host (`'[style.--swap-spring]': 'spring'`) y usa `transition: transform 0.34s var(--swap-spring)`. La `opacity` va aparte, con `0.16s ease`, porque está acotada entre 0 y 1 y el sobrepaso del resorte se perdería.
6. El acento entra 50 ms después, de `scale(0.6)` con opacidad 0 a `scale(1)` con opacidad 1, con las mismas duraciones.
7. Con `prefers-reduced-motion: reduce`, transiciona solo la opacidad (0.12 s): se conserva el cambio de icono y se quita el movimiento.
8. `aria-hidden="true"` en el host: el nombre accesible lo da el texto del botón.

Entrégame el componente completo y un ejemplo en un enlace «Empezar» que pase de `arrowRightIcon` a `rocketIcon`, con una estela de tres puntos detrás del cohete como acento.

¿Te falta un patrón, o quieres armar el tuyo?