Sik.limited Logo

La diferencia entre código que funciona y código bien resuelto está en estos detalles

Construí una interfaz con una barra de pestañas flotante sobre un canvas 3D. La primera versión funcional llevó media jornada; lograr que se sintiera bien llevó mucho más. Un registro de los problemas encontrados y lo aprendido.

Sik · ·

Rediseñé una pantalla que mantiene una vista 3D sobre un canvas WebGL y coloca encima una barra de pestañas inferior.
Al pulsar una pestaña, un panel se eleva sobre el 3D o se navega a otra página.

La primera versión, la que «simplemente funcionaba», la hice de un clic. Funcionaba, nada más. Cada pulsación se sentía rara y un poco barata. Llegar de ahí a una versión que se sintiera bien llevó mucho más tiempo.

Construir algo suele ser rápido; pulirlo tarda el doble. También cuesta automatizar esa parte: por mucho que cubras el flujo con arneses, el resultado generado no acaba de gustarte.

Al final, es mucho mejor corregir y tallar cada detalle uno a uno.

Lo que se mueve, con transform

Al principio ampliaba la altura del panel con un spring cuando subía: cambiaba height en cada fotograma. Pensé que se vería fluido, pero cada toque hacía caer los frames.

El motivo era simple. Si cambias height en cada fotograma, el navegador recalcula el layout en cada fotograma. Además, el panel tenía blur y detrás había un 3D en tiempo real. Layout, recálculo del blur y composición 3D coincidían en cada frame: la peor combinación posible.

El pipeline de renderizado del navegador sigue Layout → Paint → Composite. Propiedades como width, height, top y left vuelven a ejecutar todo desde Layout; transform y opacity solo afectan la etapa final, Composite. La GPU solo tiene que mover una capa ya dibujada.

Por eso abandoné el morph que ampliaba la altura y cambié a una hoja de altura fija que se desliza con translateY. También quité el blur del panel en movimiento y usé un fondo sólido: recalcular blur sobre un 3D vivo en cada frame era lo más costoso.

Durante una interacción, es más seguro limitar las propiedades animadas a transform y opacity. Animar propiedades de layout propaga el coste por todo el árbol de hijos.


El indicador de selección —la caja que sigue a la pestaña pulsada— aparecía una y otra vez donde no debía. Al principio tomaba cada botón, leía offsetLeft y offsetWidth y movía el indicador a esa posición.

Había dos problemas. Uno era el timing: la medición solo es correcta después de que el layout se estabilice, pero el montaje o la carga de fuentes modificaban el ancho y desajustaban el instante de medir; a veces la primera medida quedaba fijada en 0. El otro era el sistema de coordenadas: offsetLeft se mide respecto al borde del padre, mientras que el left:0 de un indicador posicionado absolutamente se mide desde la caja de padding. Siempre había una diferencia equivalente al padding del padre, y el offset añadido para corregirla rompía otro caso.

La conclusión fue que el patrón de leer el layout y escribir de nuevo a partir de él es frágil. El layout tiene que estar resuelto justo al leerlo, y esa garantía es más difícil de conseguir de lo que parece.

Así que eliminé por completo las mediciones. Convertí los botones en cuadrados de tamaño fijo y coloqué el indicador mediante un cálculo puro a partir de activeIndex. La posición sale de multiplicar el índice por el tamaño del paso. Sin leer una sola línea del DOM desaparecieron tanto los problemas de timing como los desajustes de coordenadas.

No midas un valor que puedes calcular. El código que reacciona leyendo el layout debe ser realmente el último recurso.

Usar bien el ease-in y el ease-out

Me preguntaba: «al abrir el panel está bien, pero ¿por qué al cerrarlo se corta de golpe?». La causa era haber usado una sola curva de desaceleración (ease-out) para una transición bidireccional. Es algo que ocurre a menudo en Svelte.

Al abrir, una curva de desaceleración aterriza de forma agradable. Pero si al cerrar reproduces la misma curva invirtiendo solo el progreso, el valor permanece casi abierto y cae bruscamente al final. En el tiempo, se percibe como «casi abierto y, de repente, cerrado al final».

La entrada y la salida necesitan movimientos distintos desde el inicio. Al entrar, debe desacelerar para asentarse; al salir, acelerar para irse. Por eso asigné ease-out a la entrada y ease-in a la salida, ambas con una duración de 240 ms.

El easing no es un adorno: comunica el significado del movimiento. Entrada ≠ salida.

Un spring no es magia

Al mover el indicador con un spring recibí el comentario: «la caja amarilla tarda demasiado en seguir». Un spring se basa en la física y persigue su objetivo de forma gradual. Es suave, pero en una interfaz como el resaltado de una pestaña —que debería adherirse casi en el instante del toque— esa demora parece lenta. También molestaban el pequeño rebote y el movimiento inicial no deseado que deslizaba el indicador desde 0 hasta la posición activa al montar.

Quité el spring y lo sustituí por una transición CSS: un ease-out rápido de 200 ms. Se pega inmediatamente a la pestaña y, con toques seguidos, la transición en curso cambia de dirección de forma natural desde la posición actual. Además, las transiciones CSS no se aplican al valor inicial del primer render, por lo que desapareció el deslizamiento al montar.

Para resaltar un cambio de estado, una transición CSS determinista e interrumpible encaja mejor que un spring físico. Más caro y complejo no siempre significa mejor.

Usar bien z-index

Una superposición HTML flotante sobre el 3D —como un bocadillo sobre la cabeza de un personaje— se colocaba por encima de la barra de pestañas y los paneles. Por más que aumentara el z de la barra, no podía superarla.

Al seguir el código fuente, vi que la biblioteca había asignado deliberadamente a esa superposición un z-index de varios millones para que se viera sobre el canvas. Como efecto secundario, cubría cualquier otra interfaz.

La clave real era el contexto de apilamiento. Si esa superposición tuviera de verdad un z de diez millones en la raíz de la página, ninguna barra de pestañas podría vencerla. Pero si está contenida en un contexto con z bajo, ese diez millones solo tiene significado dentro de aquel contexto; para la página es simplemente el z de ese contexto. Un elemento creado con position + z-index genera un nuevo contexto de apilamiento, y el z de sus hijos no puede escapar fuera.

Por eso fijé dos cosas. Primero, limité directamente el rango z de la superposición de la biblioteca para encerrarla «por encima del canvas y por debajo de la UI flotante». Segundo, diseñé deliberadamente el orden completo de capas y lo dejé documentado: canvas < overlay < backdrop < barra de pestañas y pop-ups < splash < toast.

Cuando 3D o WebGL se mezclan con el DOM, z-index no es una competición de números grandes, sino un problema de diseño de contextos de apilamiento. Si una biblioteca usa z de millones, no lo aumentes: enciérralo.

El 80 % de lo que «se siente raro» es falta de detalle

Pensé: «el diseño del botón de pestaña seleccionado se siente raro, sobre todo la animación».

El principal culpable era el layout shift. En estado inactivo había solo icono y en activo icono más etiqueta, así que cada toque desplazaba el icono del centro hacia la izquierda y lo recolocaba. El fade de la etiqueta y el indicador deslizante se movían por separado, lo que añadía ruido.

Hice ajustes pequeños. Eliminé la etiqueta para que el icono no saltara al seleccionar; alineé el radio interior con el exterior menos el padding para formar círculos concéntricos; añadí una respuesta de presión que reduce levemente el tamaño (aprox. 0,96; menos de 0,95 parece exagerado), amplié un poco el icono activo y engrosé su trazo. Quité el rebote del resaltado, declaré solo las propiedades que se mueven en vez de transition: all y mantuve el área táctil por encima del mínimo recomendado.

La mayor parte del feedback de «se siente raro» es la suma de detalles mínimos: layout shift, radios desalineados o ausencia de respuesta. Cada uno parece trivial; juntos producen una sensación barata.

También hay que pensar en la red

«Me molesta que cada vez que abro esta pantalla se vuelva a pedir todo por API». No es un problema de píxeles sino de flujo de datos, pero afecta directamente a la fluidez percibida.

El panel se desmontaba al cerrarse y se montaba de nuevo al abrirse, así que recibía toda la lista desde cero cada vez. Había datos guardados localmente y la pantalla no quedaba vacía, pero seguía solicitando el payload completo. Lo curioso es que otros datos de la misma pantalla ya estaban bien resueltos: caché con TTL e invalidación en mutaciones. Solo faltaba esa protección en un lugar.

Así que añadí el guard de TTL solo donde faltaba. Si ya había una respuesta correcta con las mismas condiciones dentro de un periodo, se omitía la red. Como el contenido cambia casi por días, bastaba un TTL corto. La idea clave es stale-while-revalidate: mostrar la caché de inmediato e invalidarla explícitamente cuando ocurra una mutación, como una compra o un borrado.

«Volver a cargar cada vez que se abre» es la solución perezosa. La respuesta real es caché más invalidación por mutación. Y dejar intacto el código que ya funciona también es ingeniería.



«Se cierra de golpe», «va tarde», «se siente raro». Al examinar esas molestias vagas, todas tenían una causa concreta. Al final, este trabajo parece una cuestión de acortar todo lo posible ese bucle de feedback. Pelearse intelectualmente con Claude Code es divertido, pero pulir detalles así una y otra vez cansa; construir funciones es más divertido y el pulido le quita algo de esa alegría.

Crear una función que funcione lleva media jornada. La distancia restante hasta un código que se siente bien: ahí se va todo el tiempo.


Comentarios

J
James

height 매 프레임 바꿔서 프레임 떨어졌다는 거 완전 국룰 실수ㅋㅋ transform으로 갈아탄 게 신의 한 수네

j
jun

offsetLeft는 border box 기준이고 절대배치 left:0은 padding box 기준이라 어긋난다는 거 오늘 처음 알았다

와사비초콜렛

ease-out만 양방향으로 쓰면 닫을 때 훅 떨어지는 거 완전 공감ㅋㅋ 근데 240ms는 어떻게 정한 숫자야?