Ratios de contraste WCAG
El contraste entre el color del texto y el fondo determina si el texto es legible para personas con baja visión o daltonismo. WCAG 2.1 define dos niveles:
# WCAG AA (mínimo recomendado):
# → Texto normal (< 18pt o < 14pt bold): ratio mínimo 4.5:1
# → Texto grande (≥ 18pt o ≥ 14pt bold): ratio mínimo 3:1
# → Componentes UI e íconos informativos: ratio mínimo 3:1
# WCAG AAA (máximo accesible):
# → Texto normal: ratio mínimo 7:1
# → Texto grande: ratio mínimo 4.5:1
# Ejemplos de ratios:
# Negro (#000000) sobre blanco (#FFFFFF): 21:1 → excelente
# Gris claro (#999999) sobre blanco (#FFFFFF): 2.85:1 → FALLA AA
# Material Blue (#1976D2) sobre blanco (#FFFFFF): 4.78:1 → pasa AA para texto normal
# Gris placeholder (#AAAAAA) sobre blanco (#FFFFFF): 2.32:1 → FALLA
# Herramienta online para verificar:
# webaim.org/resources/contrastchecker/
# También: Android Studio → Layout Inspector → Accessibility
# Material3 usa colores de sistema que pasan AA automáticamente
# El problema aparece con colores custom, placeholders y texto deshabilitado
// En Compose — verificar contraste en código:
// No hay una API para calcularlo en runtime, pero el Accessibility Scanner lo detecta
// Para texto de placeholder/hint — Material3 ya lo maneja:
OutlinedTextField(
value = valor,
onValueChange = { valor = it },
label = { Text("Email") }, // el label tiene contraste correcto automáticamente
placeholder = { Text("[email protected]") } // placeholder con contraste correcto
)
// ❌ Color de texto con contraste insuficiente:
Text(
text = "Texto secundario",
color = Color(0xFFBBBBBB) // #BBBBBB sobre blanco = 1.76:1 → falla
)
// ✓ Usar colores del tema que garantizan contraste:
Text(
text = "Texto secundario",
color = MaterialTheme.colorScheme.onSurfaceVariant
// onSurfaceVariant está diseñado para tener contraste correcto sobre surfaceVariant
)
Touch targets — 48dp mínimo
Los elementos interactivos deben tener al menos 48x48dp de área táctil, aunque visualmente sean más pequeños. Esto aplica a botones, checkboxes, íconos clickables, y cualquier área que el usuario deba tocar.
// ❌ Ícono de 24dp sin área táctil extra — difícil de tocar con precisión
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Cerrar",
modifier = Modifier
.size(24.dp)
.clickable { onCerrar() }
// Área táctil: 24x24dp — falla el criterio de 48dp
)
// ✓ Opción 1: usar IconButton que ya tiene 48x48dp de área táctil
IconButton(onClick = onCerrar) {
Icon(Icons.Default.Close, contentDescription = "Cerrar")
}
// ✓ Opción 2: agrandar el área táctil con minimumInteractiveComponentSize
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Cerrar",
modifier = Modifier
.size(24.dp)
.minimumInteractiveComponentSize() // garantiza 48x48dp táctil
.clickable { onCerrar() }
)
// ✓ Opción 3: padding para expandir el área táctil
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Cerrar",
modifier = Modifier
.clickable { onCerrar() }
.padding(12.dp) // 24dp visual + 12dp padding en cada lado = 48dp táctil
.size(24.dp)
)
Texto con sp — siempre para tamaños de fuente
// ❌ Tamaño de texto en dp — no respeta las preferencias del usuario
Text(
text = "Título",
fontSize = 20.dp.value.sp // MAL — dp no escala con accesibilidad
)
// O en XML: android:textSize="20dp"
// ✓ Tamaño de texto en sp — respeta el escalado del sistema
Text(
text = "Título",
fontSize = 20.sp // BIEN — escala cuando el usuario activa texto grande
)
// O en XML: android:textSize="20sp"
// Verificar que nada se corta cuando el usuario pone "Texto más grande" en
// Configuración → Accesibilidad → Tamaño del texto
// Con escala de 1.3x a 1.5x, todos los textos deben seguir siendo legibles
Respetar texto grande del sistema en Compose
// El problema: layouts que cortan texto cuando el usuario agrandó la fuente del sistema
// El Box con height fijo es el culpable más frecuente:
// ❌ Altura fija que corta texto grande
Box(modifier = Modifier.height(48.dp)) {
Text("Este texto puede ser más largo con accesibilidad activada")
// Con escala de fuente 1.5x, el texto se corta
}
// ✓ Altura mínima en lugar de fija
Box(modifier = Modifier.heightIn(min = 48.dp)) {
Text("Este texto puede crecer verticalmente si es necesario")
}
// ✓ wrapContentHeight para contenedores de texto
Card(modifier = Modifier.wrapContentHeight()) {
Text(
text = descripcion,
modifier = Modifier.padding(16.dp)
)
}
// Para verificar en el emulador:
// adb shell settings put system font_scale 1.3 (escala al 130%)
// adb shell settings put system font_scale 1.0 (volver al default)
// Configuración → Accesibilidad → Tamaño del texto → Muy grande
reduceMotion — respetar preferencias de movimiento
Algunas personas con vestibular disorders o epilepsia fotosensible pueden verse afectadas por animaciones intensas. Android tiene una configuración de "Reducir animaciones" que las apps deben respetar:
// Verificar si el usuario activó "Quitar animaciones" o "Reducir movimiento"
@Composable
fun animacionRespetandoPreferencias(): AnimationSpec<Float> {
val reducirMovimiento = LocalAccessibilityManager.current
?.isReduceMotionEnabled ?: false
return if (reducirMovimiento) {
// Sin animación — cambio instantáneo
snap()
} else {
// Animación normal
spring(dampingRatio = Spring.DampingRatioMediumBouncy)
}
}
// Para AnimatedVisibility — respetar reduceMotion:
@Composable
fun ContenidoConEntrada(visible: Boolean, content: @Composable () -> Unit) {
val reducirMovimiento = LocalAccessibilityManager.current
?.isReduceMotionEnabled ?: false
AnimatedVisibility(
visible = visible,
enter = if (reducirMovimiento) EnterTransition.None else fadeIn() + slideInVertically(),
exit = if (reducirMovimiento) ExitTransition.None else fadeOut() + slideOutVertically()
) {
content()
}
}
// En Views (sin Compose):
// Verificar el valor de "Animator Duration Scale" en Configuración del desarrollador
// Si está en 0x, las animaciones deben ser instantáneas
Dark mode y accesibilidad
// Dark mode no es solo estética — para muchas personas con fotosensibilidad
// o migrañas, la pantalla oscura es una necesidad de accesibilidad
// Material3 maneja dark mode automáticamente con el sistema de colores dinámicos
// Los contrastes WCAG se deben verificar en AMBOS temas:
// Verificar contraste en dark mode:
// → "onSurface" sobre "surface" en dark theme
// → "onPrimary" sobre "primary" en dark theme
// Error común: hardcodear un color que es visible en light pero no en dark:
Text(
text = "Texto",
color = Color.Gray // puede tener contraste insuficiente en dark mode
)
// ✓ Usar siempre colores del tema:
Text(
text = "Texto",
color = MaterialTheme.colorScheme.onBackground // contraste correcto en ambos temas
)