Documentar el movimiento en un design system
Qué contiene la sección de movimiento de un sistema de diseño, cómo se distribuyen los tokens a CSS, JavaScript y diseño, la regla de lint que impide las desviaciones, y cómo conseguir que un equipo entero lo aplique igual.
Un sistema de movimiento que vive en la cabeza de una persona no es un sistema: es un cuello de botella. Que veinte personas apliquen las mismas reglas sin haberlas leído exige tres cosas que hay que construir a propósito —que lo correcto sea lo que sale por defecto, que desviarse sea visible, y que la razón de cada número esté escrita— y ninguna de las tres se consigue publicando un documento y esperando lo mejor.
- Estructurar la sección de movimiento de un sistema de diseño con sus seis partes.
- Distribuir los mismos tokens a CSS, a JavaScript y a la herramienta de diseño desde una fuente única.
- Configurar una regla de lint que impida escribir duraciones y curvas a mano.
- Diseñar el proceso para que el equipo aplique el sistema sin tener que recordarlo.
Las seis partes
Uno. Los principios. Cuatro o cinco frases, no una página. Son lo que se cita cuando hay que decidir algo que la documentación no cubre, y por eso tienen que ser afirmaciones con las que se pueda estar en desacuerdo. Ejemplo de un conjunto utilizable:
El movimiento explica un cambio de estado; si no explica nada, sobra. Lo que responde a un dedo responde dentro de 100 ms. Lo que se hace muchas veces al día se mueve poco o nada. Los objetos entran por donde viven y salen por donde entraron. Todo movimiento tiene una versión reducida diseñada, no apagada.
Dos. Los tokens. La escala de duraciones y el catálogo de curvas, con el valor, el nombre y la razón de cada uno. La razón es la parte que evita que alguien los cambie dentro de un año sin saber qué está deshaciendo.
Tres. Las reglas de aplicación. Las tablas: duración por distancia, duración por frecuencia, curva por rol, escalonado con sus topes. Esto es lo que se consulta a diario.
Cuatro. El catálogo de patrones. Cada patrón de interfaz con su receta: qué se anima, con qué token, en qué orden, y un ejemplo funcionando. Entrada y salida de panel, modal, menú, tooltip, aviso, lista que se reordena, acordeón, cambio de pestaña, carga.
Cinco. La tabla de movimiento reducido. Qué sustituye a qué. Es la parte que más se olvida y la que hace que la versión reducida sea una decisión de diseño en lugar de un apagado automático.
Seis. La lista de revisión. Lo que se comprueba antes de aprobar un cambio con movimiento. Corta, para que se use.
Los tokens en tres destinos
Los mismos valores tienen que llegar a CSS, a JavaScript y a la herramienta de diseño. La única forma sostenible es una fuente única y una generación.
// tokens/movimiento.json — la fuente de verdad
{
"duracion": {
"instante": 0,
"micro": 100,
"corta": 150,
"media": 250,
"larga": 400,
"extra": 600
},
"curva": {
"entrada": [0, 0, 0.2, 1],
"salida": [0.4, 0, 1, 1],
"estandar": [0.4, 0, 0.2, 1],
"enfasis": [0.34, 1.56, 0.64, 1]
},
"escalonado": { "paso": 40, "maximo": 6 }
}
// scripts/generar-tokens.mjs
import { readFileSync, writeFileSync } from "node:fs";
const t = JSON.parse(readFileSync("tokens/movimiento.json", "utf8"));
const bez = (c) => `cubic-bezier(${c.join(", ")})`;
const completo = [
...Object.entries(t.duracion).map(([k, v]) => ` --mov-${k}: ${v}ms;`),
...Object.entries(t.curva).map(([k, v]) => ` --mov-${k}: ${bez(v)};`),
` --mov-paso: ${t.escalonado.paso}ms;`,
` --mov-distancia: 1;`,
].join("\n");
// La version reducida: duraciones acortadas, curvas neutras, sin desplazamiento.
const reducido = [
...Object.keys(t.duracion).map((k) => ` --mov-${k}: ${k === "instante" ? 0 : 120}ms;`),
...Object.keys(t.curva).map((k) => ` --mov-${k}: linear;`),
` --mov-paso: 0ms;`,
` --mov-distancia: 0;`,
].join("\n");
writeFileSync(
"src/estilos/movimiento.css",
`/* Generado desde tokens/movimiento.json. No editar a mano. */\n` +
`:root {\n${completo}\n}\n\n` +
`@media (prefers-reduced-motion: reduce) {\n :root {\n${reducido}\n }\n}\n`
);
writeFileSync(
"src/estilos/movimiento.js",
`// Generado desde tokens/movimiento.json. No editar a mano.\n` +
`export const movimiento = ${JSON.stringify(t, null, 2)};\n` +
`export const curva = (n) => "cubic-bezier(" + movimiento.curva[n].join(", ") + ")";\n`
);
Con eso, el CSS y el JavaScript nunca se desincronizan, y la versión de movimiento reducido se genera del mismo sitio en lugar de mantenerse a mano. Para la herramienta de diseño, el mismo JSON alimenta las variables de la biblioteca, de modo que el prototipo y el producto usan los mismos números y las revisiones dejan de tener esa conversación.
La regla de lint
La documentación no impide que alguien escriba transition: all 0.3s ease. Una regla de lint sí, y es la diferencia entre un sistema que se aplica y uno que se cita.
{
"rules": {
"declaration-property-value-allowed-list": {
"transition-duration": ["/^var\\(--mov-/"],
"animation-duration": ["/^var\\(--mov-/"],
"transition-timing-function": ["/^var\\(--mov-/", "linear", "steps(/.*/)"],
"animation-timing-function": ["/^var\\(--mov-/", "linear", "steps(/.*/)"]
},
"declaration-property-value-disallowed-list": {
"transition-property": ["all"],
"transition": ["/\\ball\\b/"]
}
}
}
Las dos reglas hacen cosas complementarias. La primera obliga a que toda duración y toda curva vengan de un token. La segunda prohíbe transition: all, que es la fuente principal de animaciones fantasma: anima propiedades que nadie quería animar, cuesta trabajo en cada cambio de estado, y es invisible en el código porque parece una línea razonable.
Lo importante de estas reglas no es que bloqueen: es que hacen visible la desviación en el momento en que se escribe, cuando corregirla cuesta diez segundos, en lugar de en una auditoría seis meses después, cuando ya hay cuarenta sitios.
Prohibir sin válvula de escape produce que alguien desactive la regla en el fichero entero. Deja un comentario de excepción con motivo obligatorio, del tipo stylelint-disable-next-line seguido de una explicación. La excepción documentada es información; la regla desactivada en silencio es deuda.
Que el equipo lo aplique sin recordarlo
Cuatro mecanismos, en orden de eficacia. El primero vale más que los otros tres juntos.
Lo correcto tiene que ser lo que sale por defecto. Si los componentes del sistema de diseño ya vienen con los tokens aplicados, la mayoría de las personas nunca tendrán que tomar una decisión de movimiento. Un panel del sistema anima como debe porque lo hace por dentro, y el noventa por ciento del movimiento del producto queda cubierto sin que nadie lea nada. Toda documentación que se pueda sustituir por un valor por defecto correcto, se sustituye.
La desviación tiene que ser visible. El lint en local y en integración continua. Además, una prueba visual con el estado congelado —usando la técnica de estabilización de animaciones— detecta cambios de movimiento que nadie mencionó en la descripción del cambio.
La razón tiene que estar escrita donde está el número. No en una página aparte que nadie abre: en un comentario junto al token.
:root {
/* 250 ms: por debajo de 200 el desplazamiento de un panel se lee como salto;
por encima de 300 se percibe lento en uso repetido. Medido con la hoja
lateral, que es el caso mas frecuente del producto. */
--mov-media: 250ms;
}
Tiene que haber alguien que decida. Un sistema sin propietario acumula excepciones hasta que deja de describir el producto. No hace falta un equipo: hace falta una persona a la que se le pregunta cuando aparece un caso nuevo, y un proceso de tres líneas para añadir un token —quién lo propone, qué caso no cubierto lo justifica, quién lo aprueba.
Y la lista de revisión, que va en la plantilla de cambios y se marca en cada uno que toque movimiento:
- ¿Las duraciones y las curvas vienen de tokens?
- ¿La duración corresponde a la distancia y a la frecuencia según las tablas?
- ¿La curva corresponde al rol: entra, sale o se mueve dentro?
- ¿La salida es más corta que la entrada?
- ¿El escalonado tiene tope y no supera los 300 ms totales?
- ¿Hay versión de movimiento reducido diseñada, no apagada?
- ¿Se anima solo
transform,opacityofilter? Si no, ¿está justificado?- ¿Qué pasa si se interrumpe a mitad?
Si observas sistemas de movimiento reales a lo largo de un par de años, el patrón de fracaso es asombrosamente consistente y no es el que la gente teme. Nadie fracasa por haber elegido 250 milisegundos en lugar de 280, ni por haber puesto el segundo punto de control de una curva en 0,2 en lugar de en 0,15. Fracasan por cobertura: el sistema describe el veinte por ciento del movimiento del producto y el ochenta restante lo escribió gente que no sabía que el sistema existía, o que lo sabía y no encontró en él el caso que necesitaba. Y una vez que la mayoría del movimiento vive fuera del sistema, el sistema deja de describir el producto, y cuando un artefacto deja de describir la realidad la gente deja de consultarlo, y a partir de ahí la degradación es irreversible por su propia dinámica. La consecuencia es que casi todo el esfuerzo debería ir a cubrir casos y a hacer que lo correcto sea el camino de menor resistencia, y muy poco a afinar los valores de los casos ya cubiertos. Media hora dedicada a añadir el patrón que falta al catálogo, o a que un componente traiga los tokens por defecto, vale más que dos días afinando la curva del modal. Y hay un corolario incómodo para quien disfruta de esta disciplina: es mejor un sistema mediocre que se aplica en todas partes que uno excelente que se aplica en tres pantallas, porque la coherencia se percibe en las relaciones entre elementos y las relaciones solo existen si los dos extremos están dentro del sistema. Un producto donde todo dura doscientos cincuenta milisegundos con la curva por defecto se siente coherente aunque sea plano; un producto con tres pantallas exquisitamente coreografiadas y cincuenta sin nada se siente exactamente igual de roto que uno sin ningún sistema, y ha costado veinte veces más.