---
title: "UI, theming and animations in SwiftUI without NativeWind or Reanimated"
description: "How to do theming, animations and accessibility in SwiftUI coming from NativeWind and Reanimated: design tokens in the Asset Catalog, Dynamic Type as the default, SF Symbols, withAnimation, matchedGeometryEffect, PhaseAnimator, native gestures and Liquid Glass as a day-one API."
author: Ramón Chancay
date: 2026-09-10
lang: en
tags: [SwiftUI, Animations, Theming, Accessibility, Dynamic Type, SF Symbols, Liquid Glass]
canonical: https://www.ramonchancay.me/blog/swiftui-ui-theming-animations-without-nativewind-reanimated
---

# UI, theming and animations in SwiftUI without NativeWind or Reanimated

In React Native, the visual layer was assembled with two tools that did not come with the framework: NativeWind for tokens and styling utilities, and Reanimated for any animation that had to run at sixty frames per second. In SwiftUI both needs exist and neither requires a library. Tokens live in the Asset Catalog and in extensions of system types, animations are a property of the state change and run outside your code, and accessibility (Dynamic Type, VoiceOver, contrast) is not a layer you add at the end but the default behavior of every component, which you only have to avoid breaking. This post covers that whole layer, and closes with Liquid Glass as an example of what the first post in the series called a day-one API.

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> TL;DR <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>Design tokens go in the Asset Catalog (colors with light and dark variants, plus high contrast) and in extensions of <code>Color</code>, <code>Font</code> and <code>ShapeStyle</code>. There are no utility classes; there are custom modifiers and component styles (<code>ButtonStyle</code>, <code>LabelStyle</code>).</li>
<li>An animation in SwiftUI is not a value you interpolate: it is an instruction that a state change should animate. <code>withAnimation</code>, <code>.animation(_:value:)</code>, <code>matchedGeometryEffect</code> and <code>PhaseAnimator</code> cover what Reanimated did with shared values and worklets, with no UI thread to manage.</li>
<li>Dynamic Type, VoiceOver and <code>prefers-reduced-motion</code> work on their own if you use system text styles and do not fix sizes. Every <code>.font(.system(size: 14))</code> and every <code>.frame(height: 44)</code> breaks them a little.</li>
</ul>
</div>
</details>

**In this article:**

- **Tokens** — [Design tokens without NativeWind](#design-tokens-without-nativewind-asset-catalog-and-extensions) · [Component styles](#component-styles-instead-of-utility-classes) · [SF Symbols](#sf-symbols-the-icons-that-ship-with-the-system)
- **Accessibility** — [Dynamic Type and VoiceOver](#dynamic-type-and-voiceover-the-standard-you-only-have-to-not-break)
- **Motion** — [Animations without Reanimated](#animations-without-reanimated-the-state-change-is-what-animates) · [Transitions and matchedGeometryEffect](#transitions-and-matchedgeometryeffect) · [PhaseAnimator and gestures](#phaseanimator-keyframes-and-native-gestures) · [Liquid Glass](#liquid-glass-a-day-one-api)

## Design tokens without NativeWind: Asset Catalog and extensions

NativeWind gave you a `tailwind.config.js` with the palette and classes like `bg-primary text-lg`. In SwiftUI the palette goes in the **Asset Catalog** (`Assets.xcassets`), where each color is defined with its variants: light and dark appearance, optionally high contrast, per device. Xcode generates named symbols for every color and every image in the catalog, so `Color.brandPrimary` exists at compile time and the compiler warns you if you rename it.

On top of that, the semantic token layer is an extension:

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

extension Color {
    // Catalog colors: light/dark variants resolved by the system.
    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 {
    // Always relative to a system text style: they scale with 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
}
```

Two decisions you notice later:

- **System semantic colors come first.** `Color.primary`, `.secondary`, `Color(.systemBackground)`, `.systemGroupedBackground` already change with dark mode, high contrast and context (a grouped `List` has a different background). Your own tokens are added for the brand, not to reinvent what the system already resolves.
- **Dark mode is not implemented; it is declared.** Every catalog color has its dark variant, and the system picks it. There is no `useColorScheme()` with ternaries in every component. If a component needs to know the scheme (rare), `@Environment(\.colorScheme)` exposes it.

To force a scheme on one screen (an onboarding that is always dark), `.preferredColorScheme(.dark)` on that view. To preview both, `#Preview(traits: .sizeThatFitsLayout)` with `.environment(\.colorScheme, .dark)`.

## Component styles instead of utility classes

What in NativeWind was `className="rounded-xl bg-accent px-4 py-3 text-white font-semibold"` repeated on every button is, in SwiftUI, a **component style**: a type that describes how a button looks, applied once with a modifier, and inherited down the tree.

```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() }
}

// Usage: the pressed and disabled states come already resolved.
Button("Continue") { submit() }
    .buttonStyle(.primary)
```

There is `ButtonStyle`, `ToggleStyle`, `LabelStyle`, `TextFieldStyle`, `ProgressViewStyle`, `MenuStyle`, and since iOS 17 styles for more containers. And for your own compositions, a `ViewModifier` with a `View` extension:

```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()) }
}
```

The difference from utility classes is not only syntax. A style applied to a container (`.buttonStyle(.primary)` on a `VStack`) affects every button inside it, and the views do not need to know which style they carry. The design system stops being a list of classes that every screen has to remember and becomes a set of decisions applied at the top.

## SF Symbols: the icons that ship with the system

`@expo/vector-icons` brought several icon families as fonts. iOS includes **SF Symbols**, more than six thousand symbols designed to align with the system typography, with nine weights, scales, variants (fill, circle, slash), rendering modes (monochrome, hierarchical, palette, multicolor) and built-in animations.

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

Image(systemName: "wifi")
    .symbolVariant(isConnected ? .none : .slash)
    .symbolEffect(.bounce, value: reconnectCount)     // built-in animation
```

Three things change compared to an icon font: symbols scale with Dynamic Type alongside the text they accompany, they align to the baseline without manual adjustments, and they have animation effects (`.bounce`, `.pulse`, `.variableColor`, `.replace`) that require nothing more than a value change. For your own icons, you import them as custom symbols into the catalog with Apple's SF Symbols app and use them with the same API.

## Dynamic Type and VoiceOver: the standard you only have to not break

In React Native, accessibility was explicit work: `accessibilityLabel`, `allowFontScaling`, testing with the screen reader. In SwiftUI, every system control already exposes its role, its label and its value to VoiceOver, and every `Text` with a system style scales with the font size the user picked in Settings. The job is not to add accessibility; it is to not remove it. The most common ways of removing it:

- **`.font(.system(size: 14))`**. Fixed size, does not scale. Replace it with `.font(.subheadline)` or with a relative size: `.font(.system(size: 14, relativeTo: .subheadline))`.
- **`.frame(height: 44)` on something that contains text**. With large text, the text gets clipped. Replace it with `minHeight`, or let the content decide.
- **An `HStack` with text and controls that do not fit at large sizes.** Use `ViewThatFits` to offer a vertical variant, or `@Environment(\.dynamicTypeSize)` to switch the layout starting at `.accessibility1`.
- **A decorative `Image` left unmarked.** VoiceOver reads it as "image". `Image(decorative: "pattern")` skips it.
- **A button built with an `onTapGesture` on a view.** It has no button role. Use `Button` with a style, which also handles the pressed state.

And the two that add value with one line:

```swift
// A group of views that VoiceOver should read as a single card.
OrderCard(order: order)
    .accessibilityElement(children: .combine)

// An action available without hunting for the button inside the card.
    .accessibilityAction(named: "Reorder") { reorder(order) }
```

On motion: `@Environment(\.accessibilityReduceMotion)` tells you whether the user asked to reduce animations. System transitions already respect it; yours should too: a `withAnimation(reduceMotion ? nil : .spring)` or an `.opacity` transition instead of `.move`. It is the equivalent of `prefers-reduced-motion` on the web, and on iOS a percentage of users turn it on.

The fast way to test all of this is Xcode's Accessibility Inspector and, in Previews, the `.dynamicTypeSize(.accessibility3)` modifier to see the screen with huge text without changing the device Settings. A screen that survives `.accessibility3` survives almost anything.

## Animations without Reanimated: the state change is what animates

Reanimated solved a real React Native problem: animations driven by React state run on the JavaScript thread and stutter when that thread is busy. That is why shared values, worklets and the UI thread existed. In SwiftUI that problem does not exist: animations are executed by the render engine outside your code, and your code only declares which state change should animate and with which curve.

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

    var body: some View {
        VStack {
            Text("Details")
            if isExpanded {
                Text("Long content that appears and disappears.")
            }
        }
        .onTapGesture {
            withAnimation(.snappy) {      // everything that changes because of this set animates
                isExpanded.toggle()
            }
        }
    }
}
```

`withAnimation` wraps the state change, and SwiftUI interpolates everything that change affects: sizes, positions, opacities, colors, even the views that appear or disappear (with a default fade transition). There is no `useSharedValue` or `useAnimatedStyle`; the animatable value is the state itself.

The second form, `.animation(_:value:)`, ties the animation to a view and to a specific value, so it animates only when that value changes, regardless of who changed it:

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

The curves: `.linear`, `.easeIn`, `.easeOut`, `.easeInOut` with a duration; `.spring` with physical parameters; and the presets `.snappy`, `.bouncy`, `.smooth` that cover most cases. Spring animations are interruptible by default: if the state changes mid-animation, the new animation starts from the current velocity, without the jump that in Reanimated required handling `withSpring` carefully.

For values that are not system types (a custom percentage, a custom shape), the `Animatable` protocol with `animatableData` tells SwiftUI how to interpolate your type. A `Shape` with an animatable `progress` is the way to draw a chart that animates when it appears.

## Transitions and matchedGeometryEffect

A **transition** defines how a view enters and leaves when it appears or disappears inside an animation:

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

`.opacity`, `.scale`, `.move(edge:)`, `.slide`, `.push(from:)`, `.blurReplace`, and `.asymmetric(insertion:removal:)` so it enters one way and leaves another. They combine with `.combined(with:)`.

**`matchedGeometryEffect`** is what replaces the "shared element transition" pattern that in React Native required a library and a bridge to the navigation. Two views with the same id and the same `Namespace` are interpreted as the same view in two places, and when the state that shows one or the other changes, SwiftUI animates the frame between the two positions:

```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 } }
            }
        }
    }
}
```

The thumbnail grows to fill the screen and comes back. The same thing between screens of a `NavigationStack` is done with `.navigationTransition(.zoom(sourceID:in:))` since iOS 18. It is the kind of effect a client asks for in the demo and that in native costs ten lines.

## PhaseAnimator, keyframes and native gestures

For animations with several stages, what in Reanimated was a `withSequence` chain or a `withRepeat`, SwiftUI has two tools since iOS 17.

**`PhaseAnimator`** walks through a list of phases and animates between them, in a loop or triggered by a value:

```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`** controls several properties on a timeline with independent curves, like a "shake" animation or a logo that enters in parts. It takes a `struct` with the animatable values and a list of tracks (`KeyframeTrack`) with their keyframes.

On **gestures**: `react-native-gesture-handler` was necessary because gestures had to be recognized outside the JavaScript thread. In SwiftUI, `DragGesture`, `MagnifyGesture`, `RotateGesture`, `LongPressGesture` and `TapGesture` are native, composable (`.simultaneously(with:)`, `.sequenced(before:)`, `.exclusively(before:)`) and their state is bound to the view's with `@GestureState`, which resets on its own when the gesture ends:

```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           // while dragging
                    }
                    .onEnded { value in
                        if abs(value.translation.width) > 120 { dismiss() }
                    }
            )
            .animation(.spring, value: offset)              // returns to center on release
    }
}
```

The `@GestureState` goes back to `.zero` when the gesture ends, and the animation tied to `offset` returns the card to the center. That is twelve lines for a "swipe to dismiss" with spring physics that can be interrupted. What is missing is the UI thread to manage, because the gesture and the animation already live there.

## Liquid Glass: a day-one API

The [first post](/blog/why-a-senior-react-native-dev-should-learn-native) mentioned Liquid Glass as an example of a "day-one API". It is worth closing with it because it shows the difference concretely.

Liquid Glass is the visual language Apple introduced in iOS 26: translucent surfaces with refraction and reflection of the content behind them, applied to system bars, controls and containers. SwiftUI apps that use standard components (`TabView`, `NavigationStack`, `toolbar`, `sheet`) adopted it by recompiling with the iOS 26 SDK, without changing code. To apply it to your own views, there is a modifier:

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

// Several glass elements that merge as they get close:
GlassEffectContainer {
    HStack {
        Button { } label: { Image(systemName: "plus") }
        Button { } label: { Image(systemName: "square.and.arrow.up") }
    }
    .buttonStyle(.glass)
}
```

What matters is not the effect but the calendar. It was announced in June 2025; by September 2025 it was in production for any app compiled with Xcode 26. In React Native, for the same result, a native module had to expose the effect, a config plugin had to integrate it, and the community navigation components had to adopt it, a process measured in months. That gap is what a client who wants to "look like Apple's apps" on launch day is paying for when they choose native.

> It is not that every app should adopt every visual change from Apple on the first day. It is that the option to do so, and to decide not to, is yours and not a module's community's.

## Frequently asked questions

### Is there something like Tailwind or NativeWind for SwiftUI?

There are experiments, and none has adoption. SwiftUI's model (component styles, custom modifiers, an environment that inherits) covers the same ground with less indirection, and utility classes do not fit well with a system that has no DOM to style. After a couple of projects, I have not missed it.

### How do I share design tokens with the web or with Android?

With a source file (Style Dictionary or Tokens Studio JSON) and a script that generates the Asset Catalog and the `Color` extension for iOS, along with the CSS or the Compose file for the others. The Asset Catalog is a directory of JSON, so generating it is straightforward. Do not do it by hand twice.

### Do SwiftUI animations perform as well as Reanimated's?

The same or better, because they run in the system compositor without going through your code on every frame. What can degrade them are animations on views that are expensive to re-evaluate (a heavy `body` that gets recomputed during the animation) or effects like large blurs. Instruments with the SwiftUI template shows which `body` is being evaluated during an animation.

### How do I make a scroll-driven animation, like a header that shrinks?

With `.scrollTransition` for per-element effects based on their position in the scroll, and with `onScrollGeometryChange` (iOS 18) to read the offset and derive state. Before iOS 18, a `GeometryReader` inside the scroll with a `PreferenceKey`, which is more code and is what you will see in older examples.

### Does Lottie work in SwiftUI?

Yes, with Airbnb's official package via SPM, which includes a SwiftUI view. For complex vector animations made by a design team it is still the practical option; for everything else, the native tools in this post are usually enough.

## Conclusion

SwiftUI's visual layer does not need NativeWind or Reanimated because the framework already includes what those libraries added to React Native: tokens in the Asset Catalog with appearance variants, component styles that inherit down the tree, SF Symbols that scale and animate with the text, accessibility as default behavior you only have to not break, and animations declared on the state change that run outside your code. `withAnimation`, `matchedGeometryEffect`, `PhaseAnimator` and native gestures cover what used to require shared values and a UI thread. And Liquid Glass is the proof that in native the decision to adopt what is new is yours, the day it ships.

To get started: define the colors in the catalog with a dark variant, write a `ButtonStyle` and a card modifier before the first screen, test every view with `.dynamicTypeSize(.accessibility3)` in the Preview, and always animate with `withAnimation` on state, never with timers. The [next post](/blog/build-signing-app-store-what-eas-hid-from-you) leaves the code and goes into what EAS hid: certificates, profiles, entitlements, TestFlight, App Review and the Privacy Manifest.
