Detalles invisibles que hacen que las interfaces se sientan bien

Matheus Cardoso

Casi nada de esto se nota. Un botón que responde como esperabas, una transición que no pide atención: lo que queda es la sensación de que funciona. Detrás hay unos cientos de decisiones pequeñas, y el premio por acertarlas todas es que nadie pueda señalar ninguna.


Por qué importan los detalles

Hoy casi todo el software es suficientemente bueno, y por eso mismo decide el gusto. La gente elige herramienta por cómo se siente el conjunto, no por la lista de funciones. La belleza sigue estando poco usada en software, lo que la vuelve una ventaja barata.

El gusto se entrena. Construyes el tuyo pasando tiempo con trabajo mejor que el tuyo y preguntando, cada vez que algo se sienta bien, qué es exactamente lo que lo está logrando.

“All those unseen details combine to produce something that's just stunning, like a thousand barely audible voices all singing in tune.”Paul Graham

Animaciones

No todo necesita animarse. Si el usuario ve una acción 100 veces al día, no la anime. Si la ve ocasionalmente (modales, drawers), anime. La regla: las animaciones de UI deben estar por debajo de 300ms.

100+/dayNunca anime
OcasionalAnimación estándar

Nunca use ease-in para animaciones de UI: empieza lento, haciendo que la interfaz se sienta perezosa. Use ease-out o curvas personalizadas más fuertes:

:root {
  /* Strong ease-out for UI interactions */
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);

  /* Strong ease-in-out for on-screen movement */
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

  /* Icon crossfade curve */
  --ease-icon: cubic-bezier(0.2, 0, 0, 1);
}
ease-in
ease-out
custom

Scale on press

Un sutil scale(0.96) al hacer clic da a los botones feedback táctil. Siempre use 0.96. Nunca por debajo de 0.95, cualquier cosa menor se siente exagerado. Pruébelo:

.button {
  transition: scale 150ms ease-out;
}

.button:active {
  scale: 0.96;
}

Transiciones de íconos

Cuando los íconos cambian de estado (menú → X, enlace → check), no solo cambie la visibilidad. Anime con opacity, scale y blur: el desenfoque enmascara la transición imperfecta entre dos formas distintas, creando la ilusión de una transformación suave.

Los valores exactos: scale de 0.25 a 1, blur de 4px a 0px, opacidad de 0 a 1. Sin librería de motion, use transiciones CSS con cubic-bezier(0.2, 0, 0, 1):

click to toggle
.icon-swap {
  display: grid;
  place-items: center;
}

.icon-swap > * {
  grid-area: 1 / 1;
  transition: opacity 300ms cubic-bezier(0.2, 0, 0, 1),
              transform 300ms cubic-bezier(0.2, 0, 0, 1),
              filter 300ms cubic-bezier(0.2, 0, 0, 1);
}

.icon-visible {
  opacity: 1;
  transform: scale(1);
  filter: blur(0px);
}

.icon-hidden {
  opacity: 0;
  transform: scale(0.25);
  filter: blur(4px);
}

Entrada escalonada

No anime un container entero. Divida el contenido en bloques semánticos y escalone cada uno con ~100ms de delay. Combine opacity, translateY y blur para el efecto de entrada:

.stagger-item {
  opacity: 0;
  transform: translateY(12px);
  filter: blur(4px);
  animation: fadeInUp 400ms ease-out forwards;
}

.stagger-item:nth-child(1) { animation-delay: 0ms; }
.stagger-item:nth-child(2) { animation-delay: 100ms; }
.stagger-item:nth-child(3) { animation-delay: 200ms; }

@keyframes fadeInUp {
  to {
    opacity: 1;
    transform: translateY(0);
    filter: blur(0);
  }
}

Superficies

Border-radius concéntrico

Cuando elementos redondeados están anidados, el radio externo debe ser igual al radio interno más el padding entre ellos: outer = inner + padding. Radios iguales en padre e hijo es lo más común que hace que las interfaces se sientan mal.

concentric
same radius
/* outerRadius = innerRadius + padding */
.card {
  border-radius: 20px;
  padding: 8px;
}
.card-inner {
  border-radius: 12px; /* 20 - 8 = 12 ✓ */
}

Sombras en vez de bordes

Para cards, botones y containers, prefiera box-shadow en capas sobre bordes sólidos. Las sombras se adaptan a cualquier fondo via transparencia. Los bordes sólidos no.

shadow
border
:root {
  --shadow-border:
    0px 0px 0px 1px rgba(0, 0, 0, 0.06),
    0px 1px 2px -1px rgba(0, 0, 0, 0.06),
    0px 2px 4px 0px rgba(0, 0, 0, 0.04);
}

/* Dark mode: single white ring */
.dark {
  --shadow-border: 0 0 0 1px rgba(255, 255, 255, 0.08);
}

Tipografía

Tres reglas, una línea de CSS cada una: text-wrap: balance en headings, para quiebres equilibrados; text-wrap: pretty en párrafos, para que no quede una palabra sola al final; y font-variant-numeric: tabular-nums en cualquier número que cambia, para que el layout deje de saltar.

Aplique -webkit-font-smoothing: antialiased en el root para texto más nítido en macOS.


Rendimiento

Solo anime transform y opacity: estas propiedades saltan layout y pintura, corriendo en la GPU. Animar width, height, padding o margin dispara los tres pasos de renderizado.

transform, opacityGPU: · Anime libremente
width, height, marginGPU: · Evite

Nunca use transition: all. Siempre especifique las propiedades exactas:

/* Good: only animate what changes */
.card {
  transition: transform 200ms ease-out, opacity 200ms ease-out;
}

/* Bad: transition everything */
.card {
  transition: all 200ms ease-out;
}

Skill file

Estas recomendaciones están compiladas en un skill file para Claude Code, Cursor y herramientas similares. Funciona con Laravel Blade, Livewire y cualquier proyecto con CSS:

npx skills add matheuscarddoso/blade-ui-skill

Basado en los principios de Emil Kowalski, Ibelick y Jakub Krehel.