¿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ú → XLa 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.
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ú → Xcoreografía
campana · corazón · copiar → palomita
No hay a qué transicionar. La forma se queda y el movimiento lleva el significado.
Ver la campanauna 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 estadosMezclarlos 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
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.
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 →
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.
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>Reposo → enviando → enviado, y de regreso. send → circle-check por morph; el spinner es un <gf-icon> curado en loop.
@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 →
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.
<button [attr.aria-pressed]="liked()" (click)="toggle()">
<gf-icon [iconDef]="heartIcon" [size]="18" trigger="tap" />
{{ votes() }}
</button>Dos formas distintas para un mismo control. El icono dice qué hace ahora el botón, no qué es.
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>El mismo control, dos formas. El estado lo lleva aria-pressed; el morph solo lleva el cambio.
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>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; } }No hay a qué transicionar: la forma es la misma y el movimiento es el que lleva el significado.
<!-- Same shape, movement with intent: choreography. -->
<gf-icon [iconDef]="bellIcon" [size]="20" trigger="hover" />El campo se expande y el icono anuncia qué hará el botón a continuación.
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>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.
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>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.
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] 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.
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>Igual patrón que arriba, para un reproductor: [active] en dos posiciones, con ondas de sonido y sin ellas.
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>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.
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 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.
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>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.
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>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.
<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>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?