---
title: "UI, theming y animaciones en SwiftUI sin NativeWind ni Reanimated"
description: "Cómo hacer theming, animaciones y accesibilidad en SwiftUI viniendo de NativeWind y Reanimated: design tokens con Asset Catalog, Dynamic Type como estándar, SF Symbols, withAnimation, matchedGeometryEffect, PhaseAnimator, gestos nativos y Liquid Glass como API del día uno."
author: Ramón Chancay
date: 2026-09-10
lang: es
tags: [SwiftUI, Animaciones, Theming, Accesibilidad, Dynamic Type, SF Symbols, Liquid Glass]
canonical: https://www.ramonchancay.me/es/blog/ui-theming-y-animaciones-en-swiftui-sin-nativewind-ni-reanimated
---

# UI, theming y animaciones en SwiftUI sin NativeWind ni Reanimated

En React Native, la capa visual se armaba con dos herramientas que no venían con el framework: NativeWind para tokens y utilidades de estilo, y Reanimated para cualquier animación que tuviera que correr a sesenta cuadros por segundo. En SwiftUI las dos necesidades existen y ninguna requiere una librería. Los tokens viven en el Asset Catalog y en extensiones de tipos del sistema, las animaciones son una propiedad del cambio de estado y corren fuera de tu código, y la accesibilidad (Dynamic Type, VoiceOver, contraste) no es una capa que se agrega al final sino el comportamiento por defecto de cada componente, que solo hay que no romper. Este post cubre esa capa completa, y cierra con Liquid Glass como ejemplo de lo que el primer post de la serie llamaba una API del día uno.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> Resumen para perezosos <svg class="rc-tldr-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="rc-tldr-body">
<ul>
<li>Los design tokens van en el Asset Catalog (colores con variante clara y oscura, y de alto contraste) y en extensiones de <code>Color</code>, <code>Font</code> y <code>ShapeStyle</code>. No hay clases utilitarias; hay modifiers propios y estilos de componente (<code>ButtonStyle</code>, <code>LabelStyle</code>).</li>
<li>Una animación en SwiftUI no es un valor que interpolas: es una instrucción de que un cambio de estado se anime. <code>withAnimation</code>, <code>.animation(_:value:)</code>, <code>matchedGeometryEffect</code> y <code>PhaseAnimator</code> cubren lo que Reanimated hacía con shared values y worklets, sin hilo de UI que administrar.</li>
<li>Dynamic Type, VoiceOver y <code>prefers-reduced-motion</code> funcionan solos si usas estilos de texto del sistema y no fijas tamaños. Cada <code>.font(.system(size: 14))</code> y cada <code>.frame(height: 44)</code> los rompe un poco.</li>
</ul>
</div>
</details>

**En este artículo:**

- **Tokens** — [Design tokens sin NativeWind](#design-tokens-sin-nativewind-asset-catalog-y-extensiones) · [Estilos de componente](#estilos-de-componente-en-vez-de-clases-utilitarias) · [SF Symbols](#sf-symbols-los-iconos-que-ya-vienen-con-el-sistema)
- **Accesibilidad** — [Dynamic Type y VoiceOver](#dynamic-type-y-voiceover-el-estándar-que-solo-hay-que-no-romper)
- **Movimiento** — [Animaciones sin Reanimated](#animaciones-sin-reanimated-el-cambio-de-estado-se-anima) · [Transiciones y matchedGeometryEffect](#transiciones-y-matchedgeometryeffect) · [PhaseAnimator y gestos](#phaseanimator-keyframes-y-gestos-nativos) · [Liquid Glass](#liquid-glass-una-api-del-día-uno)

## Design tokens sin NativeWind: Asset Catalog y extensiones

NativeWind daba una `tailwind.config.js` con la paleta y clases como `bg-primary text-lg`. En SwiftUI la paleta va en el **Asset Catalog** (`Assets.xcassets`), donde cada color se define con sus variantes: apariencia clara y oscura, y opcionalmente alto contraste, por dispositivo. Xcode genera símbolos con nombre para cada color y cada imagen del catálogo, así que `Color.brandPrimary` existe en tiempo de compilación y el compilador avisa si lo renombras.

Sobre eso, la capa de tokens semánticos es una extensión:

```swift
// DesignSystem/Tokens.swift
import SwiftUI

extension Color {
    // Colores del catálogo: variantes clara/oscura resueltas por el sistema.
    static let surface = Color("Surface")
    static let surfaceRaised = Color("SurfaceRaised")
    static let accent = Color("Accent")
    static let textPrimary = Color("TextPrimary")
    static let textMuted = Color("TextMuted")
}

extension Font {
    // Siempre relativos a un estilo de texto del sistema: escalan con Dynamic Type.
    static let display = Font.system(.largeTitle, design: .serif, weight: .semibold)
    static let heading = Font.system(.title2, weight: .semibold)
    static let body = Font.system(.body)
    static let caption = Font.system(.caption, design: .monospaced)
}

enum Spacing {
    static let xs: CGFloat = 4
    static let sm: CGFloat = 8
    static let md: CGFloat = 16
    static let lg: CGFloat = 24
    static let xl: CGFloat = 40
}

enum Radius {
    static let card: CGFloat = 16
    static let control: CGFloat = 10
}
```

Dos decisiones que se notan después:

- **Los colores semánticos del sistema van primero.** `Color.primary`, `.secondary`, `Color(.systemBackground)`, `.systemGroupedBackground` ya cambian con el modo oscuro, el alto contraste y el contexto (una `List` agrupada tiene otro fondo). Los tokens propios se agregan para la marca, no para reinventar lo que el sistema resuelve.
- **El modo oscuro no se implementa; se declara.** Cada color del catálogo tiene su variante oscura, y el sistema elige. No hay `useColorScheme()` con ternarios en cada componente. Si un componente necesita saber el esquema (raro), `@Environment(\.colorScheme)` lo expone.

Para forzar un esquema en una pantalla (un onboarding siempre oscuro), `.preferredColorScheme(.dark)` en esa vista. Para previsualizar los dos, `#Preview(traits: .sizeThatFitsLayout)` con `.environment(\.colorScheme, .dark)`.

## Estilos de componente en vez de clases utilitarias

Lo que en NativeWind era `className="rounded-xl bg-accent px-4 py-3 text-white font-semibold"` repetido en cada botón, en SwiftUI es un **estilo de componente**: un tipo que describe cómo se ve un botón, se aplica una vez con un modifier, y se hereda hacia abajo por el árbol.

```swift
struct PrimaryButtonStyle: ButtonStyle {
    @Environment(\.isEnabled) private var isEnabled

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.heading)
            .padding(.horizontal, Spacing.md)
            .padding(.vertical, Spacing.sm + 4)
            .frame(maxWidth: .infinity)
            .background(isEnabled ? Color.accent : Color.textMuted, in: .rect(cornerRadius: Radius.control))
            .foregroundStyle(.white)
            .opacity(configuration.isPressed ? 0.8 : 1)
            .scaleEffect(configuration.isPressed ? 0.98 : 1)
            .animation(.easeOut(duration: 0.12), value: configuration.isPressed)
    }
}

extension ButtonStyle where Self == PrimaryButtonStyle {
    static var primary: PrimaryButtonStyle { .init() }
}

// Uso: el estado de presionado y el deshabilitado vienen resueltos.
Button("Continuar") { submit() }
    .buttonStyle(.primary)
```

Hay `ButtonStyle`, `ToggleStyle`, `LabelStyle`, `TextFieldStyle`, `ProgressViewStyle`, `MenuStyle`, y desde iOS 17 estilos para más contenedores. Y para composiciones propias, un `ViewModifier` con una extensión de `View`:

```swift
struct CardModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(Spacing.md)
            .background(Color.surfaceRaised, in: .rect(cornerRadius: Radius.card))
            .shadow(color: .black.opacity(0.06), radius: 8, y: 2)
    }
}

extension View {
    func card() -> some View { modifier(CardModifier()) }
}
```

La diferencia con las clases utilitarias no es solo de sintaxis. Un estilo aplicado a un contenedor (`.buttonStyle(.primary)` en un `VStack`) afecta a todos los botones dentro, y las vistas no necesitan saber qué estilo llevan. El design system deja de ser una lista de clases que cada pantalla recuerda y pasa a ser un conjunto de decisiones que se aplican arriba.

## SF Symbols: los iconos que ya vienen con el sistema

`@expo/vector-icons` traía varias familias de iconos como fuentes. iOS incluye **SF Symbols**, más de seis mil símbolos diseñados para alinearse con la tipografía del sistema, con nueve pesos, escalas, variantes (relleno, círculo, slash), modos de renderizado (monocromo, jerárquico, paleta, multicolor) y animaciones integradas.

```swift
Label("Favoritos", systemImage: "heart.fill")
    .symbolRenderingMode(.hierarchical)
    .foregroundStyle(.accent)

Image(systemName: "wifi")
    .symbolVariant(isConnected ? .none : .slash)
    .symbolEffect(.bounce, value: reconnectCount)     // animación integrada
```

Tres cosas que cambian respecto a una fuente de iconos: los símbolos escalan con Dynamic Type junto al texto que acompañan, se alinean a la línea base sin ajustes manuales, y tienen efectos de animación (`.bounce`, `.pulse`, `.variableColor`, `.replace`) que no requieren nada más que un cambio de valor. Para íconos propios, se importan como símbolos personalizados en el catálogo con la app SF Symbols de Apple y se usan con la misma API.

## Dynamic Type y VoiceOver: el estándar que solo hay que no romper

En React Native, la accesibilidad era trabajo explícito: `accessibilityLabel`, `allowFontScaling`, probar con el lector de pantalla. En SwiftUI, cada control del sistema ya expone su rol, su etiqueta y su valor a VoiceOver, y cada `Text` con un estilo del sistema escala con el tamaño de letra que el usuario eligió en Ajustes. El trabajo no es agregar accesibilidad; es no quitarla. Las formas más comunes de quitarla:

- **`.font(.system(size: 14))`**. Tamaño fijo, no escala. Se reemplaza por `.font(.subheadline)` o por un tamaño relativo: `.font(.system(size: 14, relativeTo: .subheadline))`.
- **`.frame(height: 44)` en algo que contiene texto**. Con letra grande, el texto se corta. Se reemplaza por `minHeight`, o se deja que el contenido decida.
- **`HStack` con texto y controles que no caben en letra grande.** Se usa `ViewThatFits` para ofrecer una variante vertical, o `@Environment(\.dynamicTypeSize)` para cambiar el layout a partir de `.accessibility1`.
- **Un `Image` decorativo sin marcar.** VoiceOver lo lee como "imagen". `Image(decorative: "pattern")` lo omite.
- **Un botón hecho con un `onTapGesture` sobre una vista.** No tiene rol de botón. Se usa `Button` con estilo, que además maneja el estado de presionado.

Y las dos que agregan valor con una línea:

```swift
// Un grupo de vistas que VoiceOver debe leer como una sola tarjeta.
OrderCard(order: order)
    .accessibilityElement(children: .combine)

// Una acción disponible sin buscar el botón dentro de la tarjeta.
    .accessibilityAction(named: "Reordenar") { reorder(order) }
```

Sobre el movimiento: `@Environment(\.accessibilityReduceMotion)` dice si el usuario pidió reducir animaciones. Las transiciones del sistema ya lo respetan; las tuyas deberían: un `withAnimation(reduceMotion ? nil : .spring)` o una transición de `.opacity` en vez de `.move`. Es el equivalente de `prefers-reduced-motion` de la web, y en iOS un porcentaje de usuarios lo activa.

La forma rápida de probar todo esto es el Accessibility Inspector de Xcode y, en las Previews, el modificador `.dynamicTypeSize(.accessibility3)` para ver la pantalla con letra enorme sin cambiar los Ajustes del dispositivo. Una pantalla que sobrevive a `.accessibility3` sobrevive a casi todo.

## Animaciones sin Reanimated: el cambio de estado se anima

Reanimated resolvía un problema real de React Native: las animaciones basadas en estado de React corren en el hilo de JavaScript y se cortan cuando ese hilo está ocupado. Por eso existían shared values, worklets y el hilo de UI. En SwiftUI ese problema no existe: las animaciones las ejecuta el motor de render fuera de tu código, y tu código solo declara qué cambio de estado debe animarse y con qué curva.

```swift
struct Expandable: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Text("Detalles")
            if isExpanded {
                Text("Contenido largo que aparece y desaparece.")
            }
        }
        .onTapGesture {
            withAnimation(.snappy) {      // todo lo que cambie por este set, se anima
                isExpanded.toggle()
            }
        }
    }
}
```

`withAnimation` envuelve el cambio de estado, y SwiftUI interpola todo lo que ese cambio afecte: tamaños, posiciones, opacidades, colores, incluso las vistas que aparecen o desaparecen (con una transición por defecto de fundido). No hay un `useSharedValue` ni un `useAnimatedStyle`; el valor animable es el estado mismo.

La segunda forma, `.animation(_:value:)`, ata la animación a una vista y a un valor concreto, para que se anime solo cuando ese valor cambia, sin importar quién lo cambió:

```swift
Circle()
    .fill(isOnline ? .green : .gray)
    .scaleEffect(isOnline ? 1 : 0.8)
    .animation(.bouncy, value: isOnline)
```

Las curvas: `.linear`, `.easeIn`, `.easeOut`, `.easeInOut` con duración; `.spring` con parámetros físicos; y las predefinidas `.snappy`, `.bouncy`, `.smooth` que cubren la mayoría de los casos. Las de resorte son interrumpibles por defecto: si el estado cambia a mitad de la animación, la nueva animación arranca desde la velocidad actual, sin el salto que en Reanimated pedía `withSpring` con cuidado.

Para valores que no son del sistema (un porcentaje propio, una forma custom), el protocolo `Animatable` con `animatableData` dice a SwiftUI cómo interpolar tu tipo. Una `Shape` con un `progress` animable es la forma de dibujar un gráfico que se anima al aparecer.

## Transiciones y matchedGeometryEffect

Una **transición** define cómo entra y sale una vista cuando aparece o desaparece dentro de una animación:

```swift
if showBanner {
    Banner()
        .transition(.move(edge: .top).combined(with: .opacity))
}
```

`.opacity`, `.scale`, `.move(edge:)`, `.slide`, `.push(from:)`, `.blurReplace`, y `.asymmetric(insertion:removal:)` para que entre de una forma y salga de otra. Se combinan con `.combined(with:)`.

**`matchedGeometryEffect`** es el que reemplaza al patrón de "shared element transition" que en React Native requería una librería y un puente con la navegación. Dos vistas con el mismo id y el mismo `Namespace` se interpretan como la misma vista en dos lugares, y al cambiar el estado que muestra una u otra, SwiftUI anima el marco entre las dos posiciones:

```swift
struct Gallery: View {
    @Namespace private var hero
    @State private var selected: Photo?

    var body: some View {
        ZStack {
            ScrollView {
                LazyVGrid(columns: [.init(.adaptive(minimum: 100))]) {
                    ForEach(photos) { photo in
                        Thumbnail(photo)
                            .matchedGeometryEffect(id: photo.id, in: hero)
                            .onTapGesture { withAnimation(.snappy) { selected = photo } }
                    }
                }
            }
            if let selected {
                FullPhoto(selected)
                    .matchedGeometryEffect(id: selected.id, in: hero)
                    .onTapGesture { withAnimation(.snappy) { self.selected = nil } }
            }
        }
    }
}
```

La miniatura crece hasta ocupar la pantalla y vuelve. Lo mismo entre pantallas de un `NavigationStack` se hace con `.navigationTransition(.zoom(sourceID:in:))` desde iOS 18. Es el tipo de efecto que un cliente pide en la demo y que en nativo cuesta diez líneas.

## PhaseAnimator, keyframes y gestos nativos

Para animaciones con varias etapas, lo que en Reanimated era una secuencia de `withSequence` o un `withRepeat`, SwiftUI tiene dos herramientas desde iOS 17.

**`PhaseAnimator`** recorre una lista de fases y anima entre ellas, en bucle o disparado por un valor:

```swift
enum Pulse: CaseIterable { case idle, grow, fade }

Image(systemName: "bell.fill")
    .phaseAnimator(Pulse.allCases, trigger: notificationCount) { view, phase in
        view
            .scaleEffect(phase == .grow ? 1.3 : 1)
            .opacity(phase == .fade ? 0.5 : 1)
    } animation: { phase in
        switch phase {
        case .idle: .smooth
        case .grow: .snappy(duration: 0.2)
        case .fade: .easeOut(duration: 0.4)
        }
    }
```

**`KeyframeAnimator`** para controlar varias propiedades en una línea de tiempo con curvas independientes, como una animación de "sacudida" o un logo que entra por partes. Recibe un `struct` con los valores animables y una lista de pistas (`KeyframeTrack`) con sus keyframes.

Sobre **gestos**: `react-native-gesture-handler` era necesario porque los gestos tenían que reconocerse fuera del hilo de JavaScript. En SwiftUI, `DragGesture`, `MagnifyGesture`, `RotateGesture`, `LongPressGesture` y `TapGesture` son nativos, componibles (`.simultaneously(with:)`, `.sequenced(before:)`, `.exclusively(before:)`) y su estado se vincula al de la vista con `@GestureState`, que se reinicia solo cuando el gesto termina:

```swift
struct DraggableCard: View {
    @GestureState private var offset: CGSize = .zero

    var body: some View {
        Card()
            .offset(offset)
            .gesture(
                DragGesture()
                    .updating($offset) { value, state, _ in
                        state = value.translation           // durante el arrastre
                    }
                    .onEnded { value in
                        if abs(value.translation.width) > 120 { dismiss() }
                    }
            )
            .animation(.spring, value: offset)              // vuelve al centro al soltar
    }
}
```

El `@GestureState` vuelve a `.zero` al terminar el gesto, y la animación atada a `offset` devuelve la tarjeta al centro. Son doce líneas para un "swipe to dismiss" con física de resorte e interrumpible. Lo que no hay es el hilo de UI que administrar, porque el gesto y la animación ya viven ahí.

## Liquid Glass: una API del día uno

El [primer post](/es/blog/por-que-un-senior-de-react-native-deberia-aprender-nativo) mencionaba Liquid Glass como ejemplo de "API del día uno". Vale la pena cerrar con él porque muestra la diferencia de forma concreta.

Liquid Glass es el lenguaje visual que Apple introdujo en iOS 26: superficies translúcidas con refracción y reflejo del contenido de fondo, aplicadas a barras, controles y contenedores del sistema. Las apps SwiftUI que usan componentes estándar (`TabView`, `NavigationStack`, `toolbar`, `sheet`) lo adoptaron al recompilar con el SDK de iOS 26, sin cambiar código. Para aplicarlo a vistas propias, hay un modifier:

```swift
FloatingActions()
    .glassEffect(.regular, in: .capsule)

// Varios elementos de vidrio que se funden al acercarse:
GlassEffectContainer {
    HStack {
        Button { } label: { Image(systemName: "plus") }
        Button { } label: { Image(systemName: "square.and.arrow.up") }
    }
    .buttonStyle(.glass)
}
```

Lo que importa no es el efecto sino el calendario. En junio de 2025 se anunció; en septiembre de 2025 estaba en producción para cualquier app que compilara con Xcode 26. En React Native, para el mismo resultado, hacía falta que un módulo nativo expusiera el efecto, que un config plugin lo integrara, y que los componentes de navegación de la comunidad lo adoptaran, un proceso que se mide en meses. Ese intervalo es lo que un cliente que quiere "verse como las apps de Apple" el día del lanzamiento está pagando cuando elige nativo.

> No es que cada app deba adoptar cada cambio visual de Apple el primer día. Es que la posibilidad de hacerlo, y de decidir no hacerlo, es tuya y no de la comunidad de un módulo.

## Preguntas frecuentes

### ¿Hay algo como Tailwind o NativeWind para SwiftUI?

Hay experimentos, y ninguno tiene adopción. El modelo de SwiftUI (estilos de componente, modifiers propios, entorno que hereda) cubre lo mismo con menos indirección, y las clases utilitarias no encajan bien con un sistema que no tiene un DOM que estilizar. Después de un par de proyectos, no lo he extrañado.

### ¿Cómo comparto tokens de diseño con la web o con Android?

Con un archivo fuente (JSON de Style Dictionary o Tokens Studio) y un script que genere el Asset Catalog y la extensión de `Color` para iOS, junto con el CSS o el archivo de Compose para los otros. El Asset Catalog es un directorio con JSON, así que generarlo es sencillo. No lo hagas a mano dos veces.

### ¿Las animaciones de SwiftUI rinden igual que las de Reanimated?

Sí o mejor, porque corren en el compositor del sistema sin pasar por tu código en cada cuadro. Lo que puede degradar son animaciones sobre vistas caras de re-evaluar (un `body` pesado que se recalcula durante la animación) o efectos como desenfoques grandes. Instruments con la plantilla de SwiftUI muestra qué `body` se están evaluando durante una animación.

### ¿Cómo hago una animación que dependa del scroll, como un header que se encoge?

Con `.scrollTransition` para efectos por elemento según su posición en el scroll, y con `onScrollGeometryChange` (iOS 18) para leer el desplazamiento y derivar estado. Antes de iOS 18, `GeometryReader` dentro del scroll con una `PreferenceKey`, que es más código y es lo que vas a ver en ejemplos antiguos.

### ¿Lottie funciona en SwiftUI?

Sí, con el paquete oficial de Airbnb por SPM, que incluye una vista de SwiftUI. Para animaciones vectoriales complejas hechas por un equipo de diseño sigue siendo la opción práctica; para todo lo demás, las herramientas nativas de este post suelen bastar.

## Conclusión

La capa visual de SwiftUI no necesita NativeWind ni Reanimated porque el framework ya incluye lo que esas librerías agregaban a React Native: tokens en el Asset Catalog con variantes de apariencia, estilos de componente que se heredan por el árbol, SF Symbols que escalan y se animan con el texto, accesibilidad como comportamiento por defecto que solo hay que no romper, y animaciones declaradas sobre el cambio de estado que corren fuera de tu código. `withAnimation`, `matchedGeometryEffect`, `PhaseAnimator` y los gestos nativos cubren lo que antes pedía shared values y un hilo de UI. Y Liquid Glass es la prueba de que en nativo la decisión de adoptar lo nuevo es tuya, el día que sale.

Para empezar: define los colores en el catálogo con variante oscura, escribe un `ButtonStyle` y un modifier de tarjeta antes de la primera pantalla, prueba cada vista con `.dynamicTypeSize(.accessibility3)` en la Preview, y anima siempre con `withAnimation` sobre estado, nunca con timers. El [siguiente post](/es/blog/build-firma-y-app-store-lo-que-eas-te-escondia) sale del código y entra en lo que EAS escondía: certificados, perfiles, entitlements, TestFlight, App Review y el Privacy Manifest.
