---
title: "MapKit Annotations: Markers, Custom Views, and Clustering"
slug: "mapkit-annotations"
category: "ios"
tags: ["ios", "mapkit", "swift", "annotations", "clustering", "swiftui", "uikit"]
status: "stable"
last_updated: 2026-08-14
summary: "How to place Marker and Annotation items on a SwiftUI Map, build custom annotation views, cluster dense pins, and keep tap targets accessible."
related: ["[[ios/mapkit]]", "[[ios/mapkit-overlays]]", "[[ios/mapkit-camera]]", "[[ios/mapkit-search]]", "[[ios/swiftui]]", "[[coding/swift]]", "[[ios/core-data]]"]
---

## Overview

Annotations are the primary way to communicate point data on a map. MapKit for SwiftUI (iOS 17+) offers `Marker` and `Annotation` as first-class view builder items. `Marker` renders a system callout pin; `Annotation` embeds an arbitrary SwiftUI view. Choosing correctly between them, managing clustering, and sizing tap targets correctly prevents the most common annotation performance and usability problems. For overlay geometry (lines, polygons), see [[ios/mapkit-overlays]]; for camera control, see [[ios/mapkit-camera]].

## Prefer `Marker` for simple pins; use `Annotation` only when you need a custom view

`Marker` is rendered by the system, batched off the main thread, and cheaper than a SwiftUI view. Use it when the content is a label plus a tint plus an optional SF Symbol glyph.

```swift
Marker("Coffee Shop", systemImage: "cup.and.saucer.fill", coordinate: shop.coordinate)
  .tint(.brown)
```

Reserve `Annotation` for branded views, views with dynamic state, or badges that need a live count. Every `Annotation` is a live SwiftUI view; fifty of them on screen at once will impact frame rate.

```swift
Annotation("Event", coordinate: event.coordinate) {
  ZStack {
    Circle().fill(.red).frame(width: 36, height: 36)
    Image(systemName: "calendar").foregroundStyle(.white)
  }
}
```

## Size tap targets to at least 44x44 points

A pin whose visual is 16x16 but whose hit area is also 16x16 is inaccessible. Pad the content area of an `Annotation` view to ensure the effective tap target meets the 44x44 minimum.

```swift
Annotation("Pin", coordinate: coordinate) {
  Image(systemName: "mappin.circle.fill")
    .font(.title)
    .padding(10)
    .contentShape(Circle())
    .accessibilityLabel("Selected pin")
}
```

For `Marker`, the system enforces a reasonable tap target automatically.

## Cluster annotations when density exceeds readability

The SwiftUI `Map` view has no built-in clustering API as of iOS 26; `MapContentBuilder` does not expose a cluster modifier or closure. Compute clusters yourself and feed the map a small array of cluster and single-pin items.

```swift
struct MapCluster: Identifiable {
  let id = UUID()
  let coordinate: CLLocationCoordinate2D
  let members: [Cafe]
}

func clusters(for places: [Cafe], gridSize: Double) -> [MapCluster] {
  var buckets: [String: [Cafe]] = [:]
  for place in places {
    let key = "\(Int(place.coordinate.latitude / gridSize)):\(Int(place.coordinate.longitude / gridSize))"
    buckets[key, default: []].append(place)
  }
  return buckets.values.map { group in
    let lat = group.map(\.coordinate.latitude).reduce(0, +) / Double(group.count)
    let lon = group.map(\.coordinate.longitude).reduce(0, +) / Double(group.count)
    return MapCluster(coordinate: .init(latitude: lat, longitude: lon), members: group)
  }
}

Map {
  ForEach(clusters(for: cafes, gridSize: gridSize(for: cameraState))) { cluster in
    if cluster.members.count > 1 {
      Annotation("", coordinate: cluster.coordinate) {
        ZStack {
          Circle().fill(.blue).frame(width: 40, height: 40)
          Text("\(cluster.members.count)").foregroundStyle(.white).bold()
        }
      }
    } else {
      Annotation(cluster.members[0].name, coordinate: cluster.coordinate) {
        CafePinView().annotationTitles(.hidden)
      }
    }
  }
}
```

Recompute `gridSize` from the camera span on `.onMapCameraChange(frequency: .onEnd)` so clusters split apart as the user zooms in. For extreme density (tens of thousands of points) or the system's native declustering animation, fall back to an `MKMapView` wrapper using `MKAnnotationView.clusteringIdentifier` and a custom `MKClusterAnnotation` view; see [[ios/mapkit]] for the UIViewRepresentable bridge pattern.

## Filter annotations to the visible region before rendering

Passing a large array of annotations into `Map` when most are off screen wastes memory and layout time. Filter against the current region.

```swift
var visibleAnnotations: [Place] {
  let region = cameraState.region
  return allPlaces.filter { region.contains($0.coordinate) }
}

Map(position: $camera) {
  ForEach(visibleAnnotations) { place in
    Marker(place.name, coordinate: place.coordinate)
  }
}
.onMapCameraChange(frequency: .onEnd) { context in
  cameraState = context
}
```

Use `.onEnd` frequency; `.continuous` fires every rendered frame and will saturate the main actor during panning.

## Bind selection to `Identifiable` values for sheet or detail integration

`Map(selection:)` takes a binding to an optional `Identifiable` value. Tap a `Marker` or `Annotation` tagged with the same value and the binding updates.

```swift
@State private var selected: Place?

Map(position: $camera, selection: $selected) {
  ForEach(places) { place in
    Marker(place.name, coordinate: place.coordinate)
      .tag(place)
  }
}
.sheet(item: $selected) { place in
  PlaceDetailView(place: place)
}
```

Keep the `tag` type consistent with the `selection` binding type. Mixing types silently produces no-op taps.

## Provide accessibility labels and traits for every annotation

VoiceOver reads annotation content aloud. A custom `Annotation` view that contains only an image with no label is silent to VoiceOver.

```swift
Annotation("", coordinate: coordinate) {
  PinView()
    .accessibilityLabel(location.name)
    .accessibilityHint("Double-tap to view details")
    .accessibilityAddTraits(.isButton)
}
```

Test with VoiceOver enabled by running the Accessibility Inspector target against the map screen. Markers surface their `title` string automatically.

## Store annotation data in a model layer, not in view state

Never compute annotation coordinates inside `body`. Place model objects in a view model or repository backed by [[ios/core-data]] or another persistence layer. The map view should receive a stable array; recomputing the array on every render re-diffs the annotation tree unnecessarily.

```swift
@Observable final class MapViewModel {
  var visiblePlaces: [Place] = []

  func updateVisible(for region: MKCoordinateRegion) {
    visiblePlaces = placeRepository.places(in: region)
  }
}
```

## Related

- [[ios/mapkit]]
- [[ios/mapkit-overlays]]
- [[ios/mapkit-camera]]
- [[ios/mapkit-search]]
- [[ios/mapkit-look-around]]
- [[ios/swiftui]]
- [[coding/swift]]
