```html SwiftUI: How to Add Custom Map Annotations (iOS 17+, 2026)

How to Implement Custom Map Annotations in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: MapKit / Annotation Updated: May 11, 2026
TL;DR

Use SwiftUI's Annotation API inside a Map to place any SwiftUI view at a coordinate. Pass your identifiable data collection to the map, then return your custom view in the Annotation closure.

import MapKit
import SwiftUI

struct Place: Identifiable {
    let id = UUID()
    let name: String
    let coordinate: CLLocationCoordinate2D
}

struct ContentView: View {
    let places = [
        Place(name: "Eiffel Tower",
              coordinate: .init(latitude: 48.8584, longitude: 2.2945))
    ]

    var body: some View {
        Map {
            ForEach(places) { place in
                Annotation(place.name, coordinate: place.coordinate) {
                    Image(systemName: "star.circle.fill")
                        .font(.title)
                        .foregroundStyle(.yellow)
                }
            }
        }
    }
}

Full implementation

The complete example below models a list of landmarks, renders each with a fully custom SwiftUI annotation view, and shows a detail sheet when the user taps a pin. Selection is tracked via a @State optional that drives the sheet presentation, keeping the map and detail panel in sync without any UIKit bridge code.

import MapKit
import SwiftUI

// MARK: – Data model

struct Landmark: Identifiable {
    let id = UUID()
    let name: String
    let category: LandmarkCategory
    let coordinate: CLLocationCoordinate2D
}

enum LandmarkCategory: String, CaseIterable {
    case food = "fork.knife"
    case nature = "leaf.fill"
    case culture = "building.columns.fill"

    var tint: Color {
        switch self {
        case .food:    return .orange
        case .nature:  return .green
        case .culture: return .indigo
        }
    }
}

// MARK: – Custom annotation view

struct LandmarkPin: View {
    let landmark: Landmark

    var body: some View {
        VStack(spacing: 0) {
            ZStack {
                Circle()
                    .fill(landmark.category.tint)
                    .frame(width: 40, height: 40)
                    .shadow(radius: 4, y: 2)
                Image(systemName: landmark.category.rawValue)
                    .font(.system(size: 18, weight: .semibold))
                    .foregroundStyle(.white)
            }
            // Teardrop tail
            Image(systemName: "arrowtriangle.down.fill")
                .font(.system(size: 10))
                .foregroundStyle(landmark.category.tint)
                .offset(y: -2)
        }
        .accessibilityLabel("\(landmark.name), \(landmark.category.rawValue) category")
    }
}

// MARK: – Detail sheet

struct LandmarkDetail: View {
    let landmark: Landmark

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: landmark.category.rawValue)
                .font(.system(size: 48))
                .foregroundStyle(landmark.category.tint)
                .padding(.top, 24)

            Text(landmark.name)
                .font(.title2.bold())

            Text("Category: \(landmark.category.rawValue)")
                .font(.subheadline)
                .foregroundStyle(.secondary)

            Map(bounds: .init(
                    centerCoordinateBounds: MKCoordinateRegion(
                        center: landmark.coordinate,
                        latitudinalMeters: 400,
                        longitudinalMeters: 400
                    )
                )
            ) {
                Annotation(landmark.name, coordinate: landmark.coordinate) {
                    LandmarkPin(landmark: landmark)
                }
            }
            .frame(height: 180)
            .clipShape(RoundedRectangle(cornerRadius: 16))
            .padding(.horizontal)

            Spacer()
        }
        .presentationDetents([.medium])
        .presentationDragIndicator(.visible)
    }
}

// MARK: – Main map view

struct MapAnnotationsView: View {
    @State private var selectedLandmark: Landmark?
    @State private var position: MapCameraPosition = .region(
        MKCoordinateRegion(
            center: .init(latitude: 48.860, longitude: 2.305),
            latitudinalMeters: 4_000,
            longitudinalMeters: 4_000
        )
    )

    private let landmarks: [Landmark] = [
        Landmark(name: "Eiffel Tower",
                 category: .culture,
                 coordinate: .init(latitude: 48.8584, longitude: 2.2945)),
        Landmark(name: "Café de Flore",
                 category: .food,
                 coordinate: .init(latitude: 48.8543, longitude: 2.3329)),
        Landmark(name: "Jardin du Luxembourg",
                 category: .nature,
                 coordinate: .init(latitude: 48.8462, longitude: 2.3372)),
        Landmark(name: "Louvre Museum",
                 category: .culture,
                 coordinate: .init(latitude: 48.8606, longitude: 2.3376)),
        Landmark(name: "Marché d'Aligre",
                 category: .food,
                 coordinate: .init(latitude: 48.8501, longitude: 2.3743)),
    ]

    var body: some View {
        Map(position: $position) {
            ForEach(landmarks) { landmark in
                Annotation(landmark.name, coordinate: landmark.coordinate, anchor: .bottom) {
                    LandmarkPin(landmark: landmark)
                        .scaleEffect(selectedLandmark?.id == landmark.id ? 1.25 : 1.0)
                        .animation(.spring(duration: 0.25), value: selectedLandmark?.id)
                        .onTapGesture { selectedLandmark = landmark }
                }
            }
        }
        .mapStyle(.standard(elevation: .realistic))
        .mapControls {
            MapUserLocationButton()
            MapCompass()
            MapScaleView()
        }
        .sheet(item: $selectedLandmark) { landmark in
            LandmarkDetail(landmark: landmark)
        }
        .ignoresSafeArea(edges: .top)
    }
}

// MARK: – Preview

#Preview {
    MapAnnotationsView()
}

How it works

  1. Annotation(label:coordinate:anchor:content:) — The iOS 17 SwiftUI-native Annotation replaces the deprecated MapAnnotation. The anchor: .bottom parameter pins the bottom-center of your view to the coordinate, so the teardrop tail points exactly at the location.
  2. LandmarkPin view — A plain SwiftUI VStack with a Circle and an Image(systemName:) forms the pin head, while a small arrowtriangle symbol acts as the tail. No UIKit, no MKAnnotationView subclass needed.
  3. Selection state & animation@State private var selectedLandmark: Landmark? drives both the scaleEffect spring animation (line 88) and the sheet presentation, keeping a single source of truth.
  4. MapCameraPosition@State private var position: MapCameraPosition gives programmatic camera control. Passing a binding to Map(position:) lets you fly to a landmark or adjust zoom without dropping down to MapKit's imperative API.
  5. mapStyle(.standard(elevation: .realistic)) — New in iOS 17, this replaces the old mapType modifier and enables 3-D building elevation alongside standard annotations with zero extra configuration.

Variants

Animated pulsing beacon annotation

struct PulsingBeacon: View {
    @State private var isPulsing = false

    var body: some View {
        ZStack {
            Circle()
                .stroke(Color.blue.opacity(0.4), lineWidth: 2)
                .frame(width: isPulsing ? 56 : 32, height: isPulsing ? 56 : 32)
                .opacity(isPulsing ? 0 : 1)
                .animation(
                    .easeOut(duration: 1.4).repeatForever(autoreverses: false),
                    value: isPulsing
                )
            Circle()
                .fill(Color.blue)
                .frame(width: 18, height: 18)
                .overlay(Circle().stroke(.white, lineWidth: 2))
        }
        .onAppear { isPulsing = true }
        .accessibilityLabel("Current location beacon")
    }
}

// Usage inside Map:
// Annotation("You", coordinate: userCoord) { PulsingBeacon() }

Clustering annotations with MapCluster

Wrap your annotation content view in an Annotation and pass a clusteringIdentifier (available via the underlying MKPointAnnotation bridge) to group overlapping pins automatically at low zoom levels. In pure SwiftUI, set .annotationTitles(.hidden) on the Map to suppress the built-in label when you're rendering your own, and use MapCluster as a sibling content item to style the merged badge.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement custom map annotations in SwiftUI for iOS 17+.
Use MapKit/Annotation API.
Make it accessible (VoiceOver labels on each pin and the map region).
Add a #Preview with realistic sample data (5 varied landmarks with coordinates).

Drop this prompt in Soarias during the Build phase after your screen map has been approved — the agent will scaffold the data model, annotation views, and sheet detail in one pass, ready to wire into your SwiftData store.

Related

FAQ

Does this work on iOS 16?

No — the Annotation API and the new Map(position:) initialiser require iOS 17.0+. On iOS 16 you would use the now-deprecated MapAnnotation API. Since iOS 16 reached end-of-life in 2025 and App Store submissions require iOS 17+ from Xcode 16, targeting iOS 17 minimum is strongly recommended for new projects.

Can I use a UIView or image asset instead of an SF Symbol inside Annotation?

Yes — the Annotation closure accepts any SwiftUI View, so you can use Image("my-asset"), AsyncImage for remote icons, or even embed a UIViewRepresentable. Keep the view lightweight — avoid heavy gradients or Canvas drawing for pins that may be rendered in bulk.

What's the UIKit equivalent?

In UIKit you subclass MKAnnotationView, override init(annotation:reuseIdentifier:), and register the class with mapView.register(_:forAnnotationViewWithReuseIdentifier:). The SwiftUI Annotation API handles all of that internally — no reuse queue management or delegate method needed.

Last reviewed: 2026-05-11 by the Soarias team.

```