diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml
index a8922937..d312b735 100644
--- a/maps-app/src/main/AndroidManifest.xml
+++ b/maps-app/src/main/AndroidManifest.xml
@@ -109,6 +109,9 @@
+
diff --git a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt
index 45925c82..2931e60f 100644
--- a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt
+++ b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt
@@ -116,6 +116,11 @@ sealed class ActivityGroup(
R.string.tile_overlay_activity_description,
TileOverlayActivity::class
),
+ Activity(
+ R.string.wms_tile_overlay_activity,
+ R.string.wms_tile_overlay_activity_description,
+ WmsTileOverlayActivity::class
+ ),
Activity(
R.string.ground_overlay_activity,
R.string.ground_overlay_activity_description,
diff --git a/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt
new file mode 100644
index 00000000..9bc11a7e
--- /dev/null
+++ b/maps-app/src/main/java/com/google/maps/android/compose/WmsTileOverlayActivity.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.maps.android.compose
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import com.google.android.gms.maps.model.CameraPosition
+import com.google.android.gms.maps.model.LatLng
+import com.google.maps.android.compose.wms.WmsTileOverlay
+
+/**
+ * This activity demonstrates how to use [WmsTileOverlay] to display a Web Map Service (WMS)
+ * layer on a map.
+ */
+class WmsTileOverlayActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ val center = LatLng(39.50, -98.35) // Center of US
+ val cameraPositionState = rememberCameraPositionState {
+ position = CameraPosition.fromLatLngZoom(center, 4f)
+ }
+
+ GoogleMap(
+ modifier = Modifier.fillMaxSize(),
+ cameraPositionState = cameraPositionState
+ ) {
+ // Example: USGS National Map Shaded Relief (WMS)
+ WmsTileOverlay(
+ urlFormatter = { xMin, yMin, xMax, yMax, _ ->
+ "https://basemap.nationalmap.gov/arcgis/services/USGSShadedReliefOnly/MapServer/WmsServer?" +
+ "SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap" +
+ "&FORMAT=image/png&TRANSPARENT=true&LAYERS=0" +
+ "&SRS=EPSG:3857&WIDTH=256&HEIGHT=256" +
+ "&BBOX=$xMin,$yMin,$xMax,$yMax"
+ },
+ transparency = 0.5f
+ )
+ }
+ }
+ }
+}
diff --git a/maps-app/src/main/res/values/strings.xml b/maps-app/src/main/res/values/strings.xml
index 2a30105c..1147de14 100644
--- a/maps-app/src/main/res/values/strings.xml
+++ b/maps-app/src/main/res/values/strings.xml
@@ -75,6 +75,9 @@
Tile Overlay
Adding a tile overlay to the map.
+ WMS Tile Overlay
+ Adding a WMS (EPSG:3857) tile overlay to the map.
+
Ground Overlay
Adding a ground overlay to the map.
diff --git a/maps-compose-utils/build.gradle.kts b/maps-compose-utils/build.gradle.kts
index d9b9b437..b3d56458 100644
--- a/maps-compose-utils/build.gradle.kts
+++ b/maps-compose-utils/build.gradle.kts
@@ -85,4 +85,6 @@ dependencies {
implementation(libs.kotlin)
implementation(libs.kotlinx.coroutines.android)
api(libs.maps.ktx.utils)
+
+ testImplementation(libs.test.junit)
}
diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt
new file mode 100644
index 00000000..52f8bcbf
--- /dev/null
+++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsTileOverlay.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.maps.android.compose.wms
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import com.google.android.gms.maps.model.TileOverlay
+import com.google.maps.android.compose.TileOverlay
+import com.google.maps.android.compose.TileOverlayState
+import com.google.maps.android.compose.rememberTileOverlayState
+
+/**
+ * A Composable that displays a Web Map Service (WMS) layer using the EPSG:3857 projection.
+ *
+ * @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates.
+ * @param state the [TileOverlayState] to be used to control the tile overlay.
+ * @param fadeIn boolean indicating whether the tiles should fade in.
+ * @param transparency the transparency of the tile overlay.
+ * @param visible the visibility of the tile overlay.
+ * @param zIndex the z-index of the tile overlay.
+ * @param onClick a lambda invoked when the tile overlay is clicked.
+ * @param tileWidth the width of the tiles in pixels (default 256).
+ * @param tileHeight the height of the tiles in pixels (default 256).
+ */
+@Composable
+public fun WmsTileOverlay(
+ urlFormatter: (xMin: Double, yMin: Double, xMax: Double, yMax: Double, zoom: Int) -> String,
+ state: TileOverlayState = rememberTileOverlayState(),
+ fadeIn: Boolean = true,
+ transparency: Float = 0f,
+ visible: Boolean = true,
+ zIndex: Float = 0f,
+ onClick: (TileOverlay) -> Unit = {},
+ tileWidth: Int = 256,
+ tileHeight: Int = 256
+) {
+ val tileProvider = remember(urlFormatter, tileWidth, tileHeight) {
+ WmsUrlTileProvider(
+ width = tileWidth,
+ height = tileHeight,
+ urlFormatter = urlFormatter
+ )
+ }
+ TileOverlay(
+ tileProvider = tileProvider,
+ state = state,
+ fadeIn = fadeIn,
+ transparency = transparency,
+ visible = visible,
+ zIndex = zIndex,
+ onClick = onClick
+ )
+}
diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt
new file mode 100644
index 00000000..925ff33a
--- /dev/null
+++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/wms/WmsUrlTileProvider.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.maps.android.compose.wms
+
+import com.google.android.gms.maps.model.UrlTileProvider
+import java.net.MalformedURLException
+import java.net.URL
+import kotlin.math.pow
+
+/**
+ * A [UrlTileProvider] for Web Map Service (WMS) layers that use the EPSG:3857 (Web Mercator)
+ * projection.
+ *
+ * @param width the width of the tile in pixels.
+ * @param height the height of the tile in pixels.
+ * @param urlFormatter a lambda that returns the WMS URL for the given bounding box coordinates
+ * (xMin, yMin, xMax, yMax) and zoom level.
+ */
+public class WmsUrlTileProvider(
+ width: Int = 256,
+ height: Int = 256,
+ private val urlFormatter: (
+ xMin: Double,
+ yMin: Double,
+ xMax: Double,
+ yMax: Double,
+ zoom: Int
+ ) -> String
+) : UrlTileProvider(width, height) {
+
+ override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
+ val bbox = getBoundingBox(x, y, zoom)
+ val urlString = urlFormatter(bbox[0], bbox[1], bbox[2], bbox[3], zoom)
+ return try {
+ URL(urlString)
+ } catch (e: MalformedURLException) {
+ null
+ }
+ }
+
+ private companion object {
+ /**
+ * The Earth's circumference in meters at the equator according to EPSG:3857.
+ */
+ private const val EARTH_CIRCUMFERENCE = 2 * 20037508.34789244
+ }
+
+ /**
+ * Calculates the bounding box for the given tile in EPSG:3857 coordinates.
+ *
+ * @return an array containing [xMin, yMin, xMax, yMax] in meters.
+ */
+ internal fun getBoundingBox(x: Int, y: Int, zoom: Int): DoubleArray {
+ val numTiles = 2.0.pow(zoom.toDouble())
+ val tileSizeMeters = EARTH_CIRCUMFERENCE / numTiles
+
+ val xMin = -20037508.34789244 + (x * tileSizeMeters)
+ val xMax = -20037508.34789244 + ((x + 1) * tileSizeMeters)
+
+ // Y is inverted in TMS/Google Maps tiles vs WMS BBOX
+ // Top of map (y=0) is +20037508.34789244
+ val yMax = 20037508.34789244 - (y * tileSizeMeters)
+ val yMin = 20037508.34789244 - ((y + 1) * tileSizeMeters)
+
+ return doubleArrayOf(xMin, yMin, xMax, yMax)
+ }
+}
diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt
new file mode 100644
index 00000000..751405e7
--- /dev/null
+++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/wms/WmsUrlTileProviderTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.maps.android.compose.wms
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Test
+
+public class WmsUrlTileProviderTest {
+
+ private val worldSize: Double = 20037508.34789244
+
+ @Test
+ public fun testGetBoundingBoxZoom0() {
+ val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
+ val bbox = provider.getBoundingBox(0, 0, 0)
+
+ // Zoom 0, Tile 0,0 should cover the entire world
+ val expected = doubleArrayOf(-worldSize, -worldSize, worldSize, worldSize)
+ assertArrayEquals(expected, bbox, 0.001)
+ }
+
+ @Test
+ public fun testGetBoundingBoxZoom1() {
+ val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
+
+ // Zoom 1, Tile 0,0 (Top Left)
+ val bbox00 = provider.getBoundingBox(0, 0, 1)
+ val expected00 = doubleArrayOf(-worldSize, 0.0, 0.0, worldSize)
+ assertArrayEquals(expected00, bbox00, 0.001)
+
+ // Zoom 1, Tile 1,1 (Bottom Right)
+ val bbox11 = provider.getBoundingBox(1, 1, 1)
+ val expected11 = doubleArrayOf(0.0, -worldSize, worldSize, 0.0)
+ assertArrayEquals(expected11, bbox11, 0.001)
+ }
+
+ @Test
+ public fun testGetBoundingBoxSpecificTile() {
+ val provider = WmsUrlTileProvider { _, _, _, _, _ -> "" }
+
+ // Zoom 2, Tile 1,1
+ // Num tiles = 4x4. Tile size = 2 * worldSize / 4 = worldSize / 2
+ // xMin = -worldSize + 1 * (worldSize/2) = -worldSize/2
+ // xMax = -worldSize + 2 * (worldSize/2) = 0
+ // yMax = worldSize - 1 * (worldSize/2) = worldSize/2
+ // yMin = worldSize - 2 * (worldSize/2) = 0
+ val bbox = provider.getBoundingBox(1, 1, 2)
+ val expected = doubleArrayOf(-worldSize / 2, 0.0, 0.0, worldSize / 2)
+ assertArrayEquals(expected, bbox, 0.001)
+ }
+}