diff --git a/scwx-qt/gl/texture2d_array.vert b/scwx-qt/gl/texture2d_array.vert
new file mode 100644
index 00000000..cd250c55
--- /dev/null
+++ b/scwx-qt/gl/texture2d_array.vert
@@ -0,0 +1,26 @@
+#version 330 core
+
+#define DEG2RAD 0.0174532925199432957692369055556f
+
+layout (location = 0) in vec2  aVertex;
+layout (location = 1) in vec2  aXYOffset;
+layout (location = 2) in vec3  aTexCoord;
+layout (location = 3) in vec4  aModulate;
+layout (location = 4) in float aAngleDeg;
+
+uniform mat4 uMVPMatrix;
+
+smooth out vec3 texCoord;
+smooth out vec4 color;
+
+void main()
+{
+   // Rotate clockwise
+   float angle  = aAngleDeg * DEG2RAD;
+   mat2  rotate = mat2(cos(angle), -sin(angle),
+                       sin(angle), cos(angle));
+
+   gl_Position = uMVPMatrix * vec4(aVertex + rotate * aXYOffset, 0.0f, 1.0f);
+   texCoord    = aTexCoord;
+   color       = aModulate;
+}
diff --git a/scwx-qt/scwx-qt.cmake b/scwx-qt/scwx-qt.cmake
index 1af0b0ae..6ee67f88 100644
--- a/scwx-qt/scwx-qt.cmake
+++ b/scwx-qt/scwx-qt.cmake
@@ -62,6 +62,7 @@ set(SRC_GL source/scwx/qt/gl/gl_context.cpp
 set(HDR_GL_DRAW source/scwx/qt/gl/draw/draw_item.hpp
                 source/scwx/qt/gl/draw/geo_icons.hpp
                 source/scwx/qt/gl/draw/geo_line.hpp
+                source/scwx/qt/gl/draw/icons.hpp
                 source/scwx/qt/gl/draw/placefile_icons.hpp
                 source/scwx/qt/gl/draw/placefile_images.hpp
                 source/scwx/qt/gl/draw/placefile_lines.hpp
@@ -72,6 +73,7 @@ set(HDR_GL_DRAW source/scwx/qt/gl/draw/draw_item.hpp
 set(SRC_GL_DRAW source/scwx/qt/gl/draw/draw_item.cpp
                 source/scwx/qt/gl/draw/geo_icons.cpp
                 source/scwx/qt/gl/draw/geo_line.cpp
+                source/scwx/qt/gl/draw/icons.cpp
                 source/scwx/qt/gl/draw/placefile_icons.cpp
                 source/scwx/qt/gl/draw/placefile_images.cpp
                 source/scwx/qt/gl/draw/placefile_lines.cpp
@@ -176,6 +178,7 @@ set(SRC_SETTINGS source/scwx/qt/settings/audio_settings.cpp
 set(HDR_TYPES source/scwx/qt/types/alert_types.hpp
               source/scwx/qt/types/font_types.hpp
               source/scwx/qt/types/github_types.hpp
+              source/scwx/qt/types/icon_types.hpp
               source/scwx/qt/types/imgui_font.hpp
               source/scwx/qt/types/layer_types.hpp
               source/scwx/qt/types/location_types.hpp
@@ -188,6 +191,7 @@ set(HDR_TYPES source/scwx/qt/types/alert_types.hpp
               source/scwx/qt/types/texture_types.hpp)
 set(SRC_TYPES source/scwx/qt/types/alert_types.cpp
               source/scwx/qt/types/github_types.cpp
+              source/scwx/qt/types/icon_types.cpp
               source/scwx/qt/types/imgui_font.cpp
               source/scwx/qt/types/layer_types.cpp
               source/scwx/qt/types/location_types.cpp
@@ -315,6 +319,7 @@ set(SHADER_FILES gl/color.frag
                  gl/texture1d.vert
                  gl/texture2d.frag
                  gl/texture2d_array.frag
+                 gl/texture2d_array.vert
                  gl/threshold.geom)
 
 set(CMAKE_FILES scwx-qt.cmake)
diff --git a/scwx-qt/scwx-qt.qrc b/scwx-qt/scwx-qt.qrc
index c949ddc3..eeed2a70 100644
--- a/scwx-qt/scwx-qt.qrc
+++ b/scwx-qt/scwx-qt.qrc
@@ -11,6 +11,7 @@
         gl/texture1d.vert
         gl/texture2d.frag
         gl/texture2d_array.frag
+        gl/texture2d_array.vert
         gl/threshold.geom
         res/audio/wikimedia/Emergency_Alert_System_Attention_Signal_20s.ogg
         res/config/radar_sites.json
diff --git a/scwx-qt/source/scwx/qt/gl/draw/geo_icons.cpp b/scwx-qt/source/scwx/qt/gl/draw/geo_icons.cpp
index e3c7b0a3..8eae57e9 100644
--- a/scwx-qt/source/scwx/qt/gl/draw/geo_icons.cpp
+++ b/scwx-qt/source/scwx/qt/gl/draw/geo_icons.cpp
@@ -1,4 +1,5 @@
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -34,36 +35,6 @@ static constexpr std::size_t kTextureBufferLength =
 // Threshold, start time, end time
 static constexpr std::size_t kIntegersPerVertex_ = 3;
 
-struct IconInfo
-{
-   IconInfo(const std::string& iconSheet,
-            std::size_t        iconWidth,
-            std::size_t        iconHeight,
-            std::int32_t       hotX,
-            std::int32_t       hotY) :
-       iconSheet_ {iconSheet},
-       iconWidth_ {iconWidth},
-       iconHeight_ {iconHeight},
-       hotX_ {hotX},
-       hotY_ {hotY}
-   {
-   }
-
-   void UpdateTextureInfo();
-
-   std::string             iconSheet_;
-   std::size_t             iconWidth_;
-   std::size_t             iconHeight_;
-   std::int32_t            hotX_;
-   std::int32_t            hotY_;
-   util::TextureAttributes texture_ {};
-   std::size_t             rows_ {};
-   std::size_t             columns_ {};
-   std::size_t             numIcons_ {};
-   float                   scaledWidth_ {};
-   float                   scaledHeight_ {};
-};
-
 struct GeoIconDrawItem
 {
    units::length::nautical_miles       threshold_ {};
@@ -126,8 +97,9 @@ public:
 
    std::mutex iconMutex_;
 
-   boost::unordered_flat_map currentIconSheets_ {};
-   boost::unordered_flat_map newIconSheets_ {};
+   boost::unordered_flat_map
+      currentIconSheets_ {};
+   boost::unordered_flat_map newIconSheets_ {};
 
    std::vector> currentIconList_ {};
    std::vector> newIconList_ {};
@@ -348,46 +320,6 @@ void GeoIcons::Deinitialize()
    p->textureBuffer_.clear();
 }
 
-void IconInfo::UpdateTextureInfo()
-{
-   texture_ = util::TextureAtlas::Instance().GetTextureAttributes(iconSheet_);
-
-   if (iconWidth_ > 0 && iconHeight_ > 0)
-   {
-      columns_ = texture_.size_.x / iconWidth_;
-      rows_    = texture_.size_.y / iconHeight_;
-   }
-   else
-   {
-      columns_ = 1u;
-      rows_    = 1u;
-
-      iconWidth_  = static_cast(texture_.size_.x);
-      iconHeight_ = static_cast(texture_.size_.y);
-   }
-
-   if (hotX_ == -1 || hotY_ == -1)
-   {
-      hotX_ = static_cast(iconWidth_ / 2);
-      hotY_ = static_cast(iconHeight_ / 2);
-   }
-
-   numIcons_ = columns_ * rows_;
-
-   // Pixel size
-   float xFactor = 0.0f;
-   float yFactor = 0.0f;
-
-   if (texture_.size_.x > 0 && texture_.size_.y > 0)
-   {
-      xFactor = (texture_.sRight_ - texture_.sLeft_) / texture_.size_.x;
-      yFactor = (texture_.tBottom_ - texture_.tTop_) / texture_.size_.y;
-   }
-
-   scaledWidth_  = iconWidth_ * xFactor;
-   scaledHeight_ = iconHeight_ * yFactor;
-}
-
 void GeoIcons::SetVisible(bool visible)
 {
    p->visible_ = visible;
@@ -408,7 +340,7 @@ void GeoIcons::AddIconSheet(const std::string& name,
    // Populate icon sheet map
    p->newIconSheets_.emplace(std::piecewise_construct,
                              std::tuple {name},
-                             std::forward_as_tuple(IconInfo {
+                             std::forward_as_tuple(types::IconInfo {
                                 name, iconWidth, iconHeight, hotX, hotY}));
 }
 
diff --git a/scwx-qt/source/scwx/qt/gl/draw/icons.cpp b/scwx-qt/source/scwx/qt/gl/draw/icons.cpp
new file mode 100644
index 00000000..8ccee650
--- /dev/null
+++ b/scwx-qt/source/scwx/qt/gl/draw/icons.cpp
@@ -0,0 +1,620 @@
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include 
+
+#include 
+
+namespace scwx
+{
+namespace qt
+{
+namespace gl
+{
+namespace draw
+{
+
+static const std::string logPrefix_ = "scwx::qt::gl::draw::icons";
+static const auto        logger_    = scwx::util::Logger::Create(logPrefix_);
+
+static constexpr std::size_t kNumRectangles        = 1;
+static constexpr std::size_t kNumTriangles         = kNumRectangles * 2;
+static constexpr std::size_t kVerticesPerTriangle  = 3;
+static constexpr std::size_t kVerticesPerRectangle = kVerticesPerTriangle * 2;
+static constexpr std::size_t kPointsPerVertex      = 9;
+static constexpr std::size_t kPointsPerTexCoord    = 3;
+static constexpr std::size_t kIconBufferLength =
+   kNumTriangles * kVerticesPerTriangle * kPointsPerVertex;
+static constexpr std::size_t kTextureBufferLength =
+   kNumTriangles * kVerticesPerTriangle * kPointsPerTexCoord;
+
+struct IconDrawItem
+{
+   boost::gil::rgba8_pixel_t modulate_ {255, 255, 255, 255};
+   double                    x_ {};
+   double                    y_ {};
+   units::degrees    angle_ {};
+   std::string               iconSheet_ {};
+   std::size_t               iconIndex_ {};
+   std::string               hoverText_ {};
+};
+
+class Icons::Impl
+{
+public:
+   struct IconHoverEntry
+   {
+      std::shared_ptr di_;
+
+      glm::vec2 otl_;
+      glm::vec2 otr_;
+      glm::vec2 obl_;
+      glm::vec2 obr_;
+   };
+
+   explicit Impl(const std::shared_ptr& context) :
+       context_ {context},
+       shaderProgram_ {nullptr},
+       uMVPMatrixLocation_(GL_INVALID_INDEX),
+       vao_ {GL_INVALID_INDEX},
+       vbo_ {GL_INVALID_INDEX},
+       numVertices_ {0}
+   {
+   }
+
+   ~Impl() {}
+
+   void UpdateBuffers();
+   void UpdateTextureBuffer();
+   void Update(bool textureAtlasChanged);
+
+   std::shared_ptr context_;
+
+   bool visible_ {true};
+   bool dirty_ {false};
+   bool lastTextureAtlasChanged_ {false};
+
+   std::mutex iconMutex_;
+
+   boost::unordered_flat_map
+      currentIconSheets_ {};
+   boost::unordered_flat_map newIconSheets_ {};
+
+   std::vector> currentIconList_ {};
+   std::vector> newIconList_ {};
+   std::vector> newValidIconList_ {};
+
+   std::vector currentIconBuffer_ {};
+   std::vector newIconBuffer_ {};
+
+   std::vector textureBuffer_ {};
+
+   std::vector currentHoverIcons_ {};
+   std::vector newHoverIcons_ {};
+
+   std::shared_ptr shaderProgram_;
+   GLint                          uMVPMatrixLocation_;
+
+   GLuint                vao_;
+   std::array vbo_;
+
+   GLsizei numVertices_;
+};
+
+Icons::Icons(const std::shared_ptr& context) :
+    DrawItem(context->gl()), p(std::make_unique(context))
+{
+}
+Icons::~Icons() = default;
+
+Icons::Icons(Icons&&) noexcept            = default;
+Icons& Icons::operator=(Icons&&) noexcept = default;
+
+void Icons::Initialize()
+{
+   gl::OpenGLFunctions& gl = p->context_->gl();
+
+   p->shaderProgram_ = p->context_->GetShaderProgram(
+      {{GL_VERTEX_SHADER, ":/gl/texture2d_array.vert"},
+       {GL_FRAGMENT_SHADER, ":/gl/texture2d_array.frag"}});
+
+   p->uMVPMatrixLocation_ = p->shaderProgram_->GetUniformLocation("uMVPMatrix");
+
+   gl.glGenVertexArrays(1, &p->vao_);
+   gl.glGenBuffers(static_cast(p->vbo_.size()), p->vbo_.data());
+
+   gl.glBindVertexArray(p->vao_);
+   gl.glBindBuffer(GL_ARRAY_BUFFER, p->vbo_[0]);
+   gl.glBufferData(GL_ARRAY_BUFFER, 0u, nullptr, GL_DYNAMIC_DRAW);
+
+   // aVertex
+   gl.glVertexAttribPointer(0,
+                            2,
+                            GL_FLOAT,
+                            GL_FALSE,
+                            kPointsPerVertex * sizeof(float),
+                            reinterpret_cast(0));
+   gl.glEnableVertexAttribArray(0);
+
+   // aXYOffset
+   gl.glVertexAttribPointer(1,
+                            2,
+                            GL_FLOAT,
+                            GL_FALSE,
+                            kPointsPerVertex * sizeof(float),
+                            reinterpret_cast(2 * sizeof(float)));
+   gl.glEnableVertexAttribArray(1);
+
+   // aModulate
+   gl.glVertexAttribPointer(3,
+                            4,
+                            GL_FLOAT,
+                            GL_FALSE,
+                            kPointsPerVertex * sizeof(float),
+                            reinterpret_cast(4 * sizeof(float)));
+   gl.glEnableVertexAttribArray(3);
+
+   // aAngle
+   gl.glVertexAttribPointer(4,
+                            1,
+                            GL_FLOAT,
+                            GL_FALSE,
+                            kPointsPerVertex * sizeof(float),
+                            reinterpret_cast(8 * sizeof(float)));
+   gl.glEnableVertexAttribArray(4);
+
+   gl.glBindBuffer(GL_ARRAY_BUFFER, p->vbo_[1]);
+   gl.glBufferData(GL_ARRAY_BUFFER, 0u, nullptr, GL_DYNAMIC_DRAW);
+
+   // aTexCoord
+   gl.glVertexAttribPointer(2,
+                            3,
+                            GL_FLOAT,
+                            GL_FALSE,
+                            kPointsPerTexCoord * sizeof(float),
+                            static_cast(0));
+   gl.glEnableVertexAttribArray(2);
+
+   p->dirty_ = true;
+}
+
+void Icons::Render(const QMapLibreGL::CustomLayerRenderParameters& params,
+                   bool textureAtlasChanged)
+{
+   if (!p->visible_)
+   {
+      if (textureAtlasChanged)
+      {
+         p->lastTextureAtlasChanged_ = true;
+      }
+
+      return;
+   }
+
+   std::unique_lock lock {p->iconMutex_};
+
+   if (!p->currentIconList_.empty())
+   {
+      gl::OpenGLFunctions& gl = p->context_->gl();
+
+      gl.glBindVertexArray(p->vao_);
+
+      p->Update(textureAtlasChanged);
+      p->shaderProgram_->Use();
+      UseDefaultProjection(params, p->uMVPMatrixLocation_);
+
+      // Interpolate texture coordinates
+      gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+      gl.glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+
+      // Draw icons
+      gl.glDrawArrays(GL_TRIANGLES, 0, p->numVertices_);
+   }
+}
+
+void Icons::Deinitialize()
+{
+   gl::OpenGLFunctions& gl = p->context_->gl();
+
+   gl.glDeleteVertexArrays(1, &p->vao_);
+   gl.glDeleteBuffers(static_cast(p->vbo_.size()), p->vbo_.data());
+
+   std::unique_lock lock {p->iconMutex_};
+
+   p->currentIconList_.clear();
+   p->currentIconSheets_.clear();
+   p->currentHoverIcons_.clear();
+   p->currentIconBuffer_.clear();
+   p->textureBuffer_.clear();
+}
+
+void Icons::SetVisible(bool visible)
+{
+   p->visible_ = visible;
+}
+
+void Icons::StartIconSheets()
+{
+   // Clear the new buffer
+   p->newIconSheets_.clear();
+}
+
+void Icons::AddIconSheet(const std::string& name,
+                         std::size_t        iconWidth,
+                         std::size_t        iconHeight,
+                         std::int32_t       hotX,
+                         std::int32_t       hotY)
+{
+   // Populate icon sheet map
+   p->newIconSheets_.emplace(std::piecewise_construct,
+                             std::tuple {name},
+                             std::forward_as_tuple(types::IconInfo {
+                                name, iconWidth, iconHeight, hotX, hotY}));
+}
+
+void Icons::FinishIconSheets()
+{
+   // Update icon sheets
+   for (auto& iconSheet : p->newIconSheets_)
+   {
+      iconSheet.second.UpdateTextureInfo();
+   }
+
+   std::unique_lock lock {p->iconMutex_};
+
+   // Swap buffers
+   p->currentIconSheets_.swap(p->newIconSheets_);
+
+   // Clear the new buffers
+   p->newIconSheets_.clear();
+
+   // Mark the draw item dirty
+   p->dirty_ = true;
+}
+
+void Icons::StartIcons()
+{
+   // Clear the new buffer
+   p->newIconList_.clear();
+   p->newValidIconList_.clear();
+   p->newIconBuffer_.clear();
+   p->newHoverIcons_.clear();
+}
+
+std::shared_ptr Icons::AddIcon()
+{
+   return p->newIconList_.emplace_back(std::make_shared());
+}
+
+void Icons::SetIconTexture(const std::shared_ptr& di,
+                           const std::string&                   iconSheet,
+                           std::size_t                          iconIndex)
+{
+   di->iconSheet_ = iconSheet;
+   di->iconIndex_ = iconIndex;
+}
+
+void Icons::SetIconLocation(const std::shared_ptr& di,
+                            double                               x,
+                            double                               y)
+{
+   di->x_ = x;
+   di->y_ = y;
+}
+
+void Icons::SetIconAngle(const std::shared_ptr& di,
+                         units::angle::degrees        angle)
+{
+   di->angle_ = angle;
+}
+
+void Icons::SetIconModulate(const std::shared_ptr& di,
+                            boost::gil::rgba8_pixel_t            modulate)
+{
+   di->modulate_ = modulate;
+}
+
+void Icons::SetIconHoverText(const std::shared_ptr& di,
+                             const std::string&                   text)
+{
+   di->hoverText_ = text;
+}
+
+void Icons::FinishIcons()
+{
+   // Update buffers
+   p->UpdateBuffers();
+
+   std::unique_lock lock {p->iconMutex_};
+
+   // Swap buffers
+   p->currentIconList_.swap(p->newValidIconList_);
+   p->currentIconBuffer_.swap(p->newIconBuffer_);
+   p->currentHoverIcons_.swap(p->newHoverIcons_);
+
+   // Clear the new buffers, except the full icon list (used to update buffers
+   // without re-adding icons)
+   p->newValidIconList_.clear();
+   p->newIconBuffer_.clear();
+   p->newHoverIcons_.clear();
+
+   // Mark the draw item dirty
+   p->dirty_ = true;
+}
+
+void Icons::Impl::UpdateBuffers()
+{
+   newIconBuffer_.clear();
+   newIconBuffer_.reserve(newIconList_.size() * kIconBufferLength);
+   newValidIconList_.clear();
+   newHoverIcons_.clear();
+
+   for (auto& di : newIconList_)
+   {
+      auto it = currentIconSheets_.find(di->iconSheet_);
+      if (it == currentIconSheets_.cend())
+      {
+         // No icon sheet found
+         logger_->warn("Could not find icon sheet: {}", di->iconSheet_);
+         continue;
+      }
+
+      auto& icon = it->second;
+
+      // Validate icon
+      if (di->iconIndex_ >= icon.numIcons_)
+      {
+         // No icon found
+         logger_->warn("Invalid icon index: {}", di->iconIndex_);
+         continue;
+      }
+
+      // Icon is valid, add to valid icon list
+      newValidIconList_.push_back(di);
+
+      // Base X/Y offsets in pixels
+      const float x = static_cast(di->x_);
+      const float y = static_cast(di->y_);
+
+      // Icon size
+      const float iw = static_cast(icon.iconWidth_);
+      const float ih = static_cast(icon.iconHeight_);
+
+      // Hot X/Y (zero-based icon center)
+      const float hx = static_cast(icon.hotX_);
+      const float hy = static_cast(icon.hotY_);
+
+      // Final X/Y offsets in pixels
+      const float lx = std::roundf(-hx);
+      const float rx = std::roundf(lx + iw);
+      const float ty = std::roundf(+hy);
+      const float by = std::roundf(ty - ih);
+
+      // Angle in degrees
+      units::angle::degrees angle = di->angle_;
+      const float                  a     = angle.value();
+
+      // Modulate color
+      const float mc0 = di->modulate_[0] / 255.0f;
+      const float mc1 = di->modulate_[1] / 255.0f;
+      const float mc2 = di->modulate_[2] / 255.0f;
+      const float mc3 = di->modulate_[3] / 255.0f;
+
+      newIconBuffer_.insert(newIconBuffer_.end(),
+                            {
+                               // Icon
+                               x, y, lx, by, mc0, mc1, mc2, mc3, a, // BL
+                               x, y, lx, ty, mc0, mc1, mc2, mc3, a, // TL
+                               x, y, rx, by, mc0, mc1, mc2, mc3, a, // BR
+                               x, y, rx, by, mc0, mc1, mc2, mc3, a, // BR
+                               x, y, rx, ty, mc0, mc1, mc2, mc3, a, // TR
+                               x, y, lx, ty, mc0, mc1, mc2, mc3, a  // TL
+                            });
+
+      if (!di->hoverText_.empty())
+      {
+         const units::angle::radians radians = angle;
+
+         const float cosAngle = cosf(static_cast(radians.value()));
+         const float sinAngle = sinf(static_cast(radians.value()));
+
+         const glm::mat2 rotate {cosAngle, -sinAngle, sinAngle, cosAngle};
+
+         const glm::vec2 otl = rotate * glm::vec2 {lx, ty};
+         const glm::vec2 otr = rotate * glm::vec2 {rx, ty};
+         const glm::vec2 obl = rotate * glm::vec2 {lx, by};
+         const glm::vec2 obr = rotate * glm::vec2 {rx, by};
+
+         newHoverIcons_.emplace_back(IconHoverEntry {di, otl, otr, obl, obr});
+      }
+   }
+}
+
+void Icons::Impl::UpdateTextureBuffer()
+{
+   textureBuffer_.clear();
+   textureBuffer_.reserve(currentIconList_.size() * kTextureBufferLength);
+
+   for (auto& di : currentIconList_)
+   {
+      auto it = currentIconSheets_.find(di->iconSheet_);
+      if (it == currentIconSheets_.cend())
+      {
+         // No file found. Should not get here, but insert empty data to match
+         // up with data already buffered
+         logger_->error("Could not find icon sheet: {}", di->iconSheet_);
+
+         // clang-format off
+         textureBuffer_.insert(
+            textureBuffer_.end(),
+            {
+               // Icon
+               0.0f, 0.0f, 0.0f, // BL
+               0.0f, 0.0f, 0.0f, // TL
+               0.0f, 0.0f, 0.0f, // BR
+               0.0f, 0.0f, 0.0f, // BR
+               0.0f, 0.0f, 0.0f, // TR
+               0.0f, 0.0f, 0.0f  // TL
+            });
+         // clang-format on
+
+         continue;
+      }
+
+      auto& icon = it->second;
+
+      // Validate icon
+      if (di->iconIndex_ >= icon.numIcons_)
+      {
+         // No icon found
+         logger_->error("Invalid icon index: {}", di->iconIndex_);
+
+         // Will get here if a texture changes, and the texture shrunk such that
+         // the icon is no longer found
+
+         // clang-format off
+         textureBuffer_.insert(
+            textureBuffer_.end(),
+            {
+               // Icon
+               0.0f, 0.0f, 0.0f, // BL
+               0.0f, 0.0f, 0.0f, // TL
+               0.0f, 0.0f, 0.0f, // BR
+               0.0f, 0.0f, 0.0f, // BR
+               0.0f, 0.0f, 0.0f, // TR
+               0.0f, 0.0f, 0.0f  // TL
+            });
+         // clang-format on
+
+         continue;
+      }
+
+      // Texture coordinates
+      const std::size_t iconRow    = (di->iconIndex_) / icon.columns_;
+      const std::size_t iconColumn = (di->iconIndex_) % icon.columns_;
+
+      const float iconX = iconColumn * icon.scaledWidth_;
+      const float iconY = iconRow * icon.scaledHeight_;
+
+      const float ls = icon.texture_.sLeft_ + iconX;
+      const float rs = ls + icon.scaledWidth_;
+      const float tt = icon.texture_.tTop_ + iconY;
+      const float bt = tt + icon.scaledHeight_;
+      const float r  = static_cast(icon.texture_.layerId_);
+
+      // clang-format off
+      textureBuffer_.insert(
+         textureBuffer_.end(),
+         {
+            // Icon
+            ls, bt, r, // BL
+            ls, tt, r, // TL
+            rs, bt, r, // BR
+            rs, bt, r, // BR
+            rs, tt, r, // TR
+            ls, tt, r  // TL
+         });
+      // clang-format on
+   }
+}
+
+void Icons::Impl::Update(bool textureAtlasChanged)
+{
+   gl::OpenGLFunctions& gl = context_->gl();
+
+   // If the texture atlas has changed
+   if (dirty_ || textureAtlasChanged || lastTextureAtlasChanged_)
+   {
+      // Update texture coordinates
+      for (auto& iconSheet : currentIconSheets_)
+      {
+         iconSheet.second.UpdateTextureInfo();
+      }
+
+      // Update OpenGL texture buffer data
+      UpdateTextureBuffer();
+
+      // Buffer texture data
+      gl.glBindBuffer(GL_ARRAY_BUFFER, vbo_[1]);
+      gl.glBufferData(GL_ARRAY_BUFFER,
+                      sizeof(float) * textureBuffer_.size(),
+                      textureBuffer_.data(),
+                      GL_DYNAMIC_DRAW);
+
+      lastTextureAtlasChanged_ = false;
+   }
+
+   // If buffers need updating
+   if (dirty_)
+   {
+      // Buffer vertex data
+      gl.glBindBuffer(GL_ARRAY_BUFFER, vbo_[0]);
+      gl.glBufferData(GL_ARRAY_BUFFER,
+                      sizeof(float) * currentIconBuffer_.size(),
+                      currentIconBuffer_.data(),
+                      GL_DYNAMIC_DRAW);
+
+      numVertices_ =
+         static_cast(currentIconBuffer_.size() / kPointsPerVertex);
+   }
+
+   dirty_ = false;
+}
+
+bool Icons::RunMousePicking(
+   const QMapLibreGL::CustomLayerRenderParameters& params,
+   const QPointF&                                  mouseLocalPos,
+   const QPointF&                                  mouseGlobalPos,
+   const glm::vec2& /* mouseCoords */,
+   const common::Coordinate& /* mouseGeoCoords */)
+{
+   std::unique_lock lock {p->iconMutex_};
+
+   bool itemPicked = false;
+
+   // Convert local coordinates to icon coordinates
+   glm::vec2 mouseLocalCoords {mouseLocalPos.x(),
+                               params.height - mouseLocalPos.y()};
+
+   // For each pickable icon
+   auto it = std::find_if( //
+      std::execution::par_unseq,
+      p->currentHoverIcons_.crbegin(),
+      p->currentHoverIcons_.crend(),
+      [&mouseLocalCoords](const auto& icon)
+      {
+         // Initialize vertices
+         glm::vec2 bl = {static_cast(icon.di_->x_),
+                         static_cast(icon.di_->y_)};
+         glm::vec2 br = bl;
+         glm::vec2 tl = br;
+         glm::vec2 tr = tl;
+
+         // Offset vertices
+         tl += icon.otl_;
+         bl += icon.obl_;
+         br += icon.obr_;
+         tr += icon.otr_;
+
+         // Test point against polygon bounds
+         return util::maplibre::IsPointInPolygon({tl, bl, br, tr},
+                                                 mouseLocalCoords);
+      });
+
+   if (it != p->currentHoverIcons_.crend())
+   {
+      itemPicked = true;
+      util::tooltip::Show(it->di_->hoverText_, mouseGlobalPos);
+   }
+
+   return itemPicked;
+}
+
+} // namespace draw
+} // namespace gl
+} // namespace qt
+} // namespace scwx
diff --git a/scwx-qt/source/scwx/qt/gl/draw/icons.hpp b/scwx-qt/source/scwx/qt/gl/draw/icons.hpp
new file mode 100644
index 00000000..7e3ac356
--- /dev/null
+++ b/scwx-qt/source/scwx/qt/gl/draw/icons.hpp
@@ -0,0 +1,154 @@
+#pragma once
+
+#include 
+#include 
+
+#include 
+#include 
+
+namespace scwx
+{
+namespace qt
+{
+namespace gl
+{
+namespace draw
+{
+
+struct IconDrawItem;
+
+class Icons : public DrawItem
+{
+public:
+   explicit Icons(const std::shared_ptr& context);
+   ~Icons();
+
+   Icons(const Icons&)            = delete;
+   Icons& operator=(const Icons&) = delete;
+
+   Icons(Icons&&) noexcept;
+   Icons& operator=(Icons&&) noexcept;
+
+   void Initialize() override;
+   void Render(const QMapLibreGL::CustomLayerRenderParameters& params,
+               bool textureAtlasChanged) override;
+   void Deinitialize() override;
+
+   bool RunMousePicking(const QMapLibreGL::CustomLayerRenderParameters& params,
+                        const QPointF&            mouseLocalPos,
+                        const QPointF&            mouseGlobalPos,
+                        const glm::vec2&          mouseCoords,
+                        const common::Coordinate& mouseGeoCoords) override;
+
+   /**
+    * Sets the visibility of the icons.
+    *
+    * @param [in] visible Icon visibility
+    */
+   void SetVisible(bool visible);
+
+   /**
+    * Resets and prepares the draw item for adding a new set of icon sheets.
+    */
+   void StartIconSheets();
+
+   /**
+    * Adds an icon sheet for drawing the icons. The icon sheet must already
+    * exist in the texture atlas.
+    *
+    * @param [in] name The name of the icon sheet in the texture atlas
+    * @param [in] iconWidth The width of each icon in the icon sheet. Default is
+    * 0 for a single icon.
+    * @param [in] iconHeight The height of each icon in the icon sheet. Default
+    * is 0 for a single icon.
+    * @param [in] hotX The zero-based center of the each icon in the icon sheet.
+    * Default is -1 to center the icon.
+    * @param [in] hotY The zero-based center of the each icon in the icon sheet.
+    * Default is -1 to center the icon.
+    */
+   void AddIconSheet(const std::string& name,
+                     std::size_t        iconWidth  = 0,
+                     std::size_t        iconHeight = 0,
+                     std::int32_t       hotX       = -1,
+                     std::int32_t       hotY       = -1);
+
+   /**
+    * Resets and prepares the draw item for adding a new set of icon sheets.
+    */
+   void FinishIconSheets();
+
+   /**
+    * Resets and prepares the draw item for adding a new set of icons.
+    */
+   void StartIcons();
+
+   /**
+    * Adds an icon to the internal draw list.
+    *
+    * @return Icon draw item
+    */
+   std::shared_ptr AddIcon();
+
+   /**
+    * Sets the texture of an icon.
+    *
+    * @param [in] di Icon draw item
+    * @param [in] iconSheet The name of the icon sheet in the texture atlas
+    * @param [in] iconIndex The zero-based index of the icon in the icon sheet
+    */
+   static void SetIconTexture(const std::shared_ptr& di,
+                              const std::string&                   iconSheet,
+                              std::size_t                          iconIndex);
+
+   /**
+    * Sets the location of an icon.
+    *
+    * @param [in] di Icon draw item
+    * @param [in] x The x location of the icon in pixels.
+    * @param [in] y The y location of the icon in pixels.
+    */
+   static void
+   SetIconLocation(const std::shared_ptr& di, double x, double y);
+
+   /**
+    * Sets the angle of an icon.
+    *
+    * @param [in] di Icon draw item
+    * @param [in] angle Angle in degrees
+    */
+   static void SetIconAngle(const std::shared_ptr& di,
+                            units::angle::degrees        angle);
+
+   /**
+    * Sets the modulate color of an icon.
+    *
+    * @param [in] di Icon draw item
+    * @param [in] modulate Modulate color
+    */
+   static void SetIconModulate(const std::shared_ptr& di,
+                               boost::gil::rgba8_pixel_t            modulate);
+
+   /**
+    * Sets the hover text of an icon.
+    *
+    * @param [in] di Icon draw item
+    * @param [in] text Hover text
+    */
+   static void SetIconHoverText(const std::shared_ptr& di,
+                                const std::string&                   text);
+
+   /**
+    * Finalizes the draw item after adding new icons.
+    */
+   void FinishIcons();
+
+private:
+   class Impl;
+
+   std::unique_ptr p;
+};
+
+} // namespace draw
+} // namespace gl
+} // namespace qt
+} // namespace scwx
diff --git a/scwx-qt/source/scwx/qt/types/icon_types.cpp b/scwx-qt/source/scwx/qt/types/icon_types.cpp
new file mode 100644
index 00000000..8323ef83
--- /dev/null
+++ b/scwx-qt/source/scwx/qt/types/icon_types.cpp
@@ -0,0 +1,52 @@
+#include 
+
+namespace scwx
+{
+namespace qt
+{
+namespace types
+{
+
+void IconInfo::UpdateTextureInfo()
+{
+   texture_ = util::TextureAtlas::Instance().GetTextureAttributes(iconSheet_);
+
+   if (iconWidth_ > 0 && iconHeight_ > 0)
+   {
+      columns_ = texture_.size_.x / iconWidth_;
+      rows_    = texture_.size_.y / iconHeight_;
+   }
+   else
+   {
+      columns_ = 1u;
+      rows_    = 1u;
+
+      iconWidth_  = static_cast(texture_.size_.x);
+      iconHeight_ = static_cast(texture_.size_.y);
+   }
+
+   if (hotX_ == -1 || hotY_ == -1)
+   {
+      hotX_ = static_cast(iconWidth_ / 2);
+      hotY_ = static_cast(iconHeight_ / 2);
+   }
+
+   numIcons_ = columns_ * rows_;
+
+   // Pixel size
+   float xFactor = 0.0f;
+   float yFactor = 0.0f;
+
+   if (texture_.size_.x > 0 && texture_.size_.y > 0)
+   {
+      xFactor = (texture_.sRight_ - texture_.sLeft_) / texture_.size_.x;
+      yFactor = (texture_.tBottom_ - texture_.tTop_) / texture_.size_.y;
+   }
+
+   scaledWidth_  = iconWidth_ * xFactor;
+   scaledHeight_ = iconHeight_ * yFactor;
+}
+
+} // namespace types
+} // namespace qt
+} // namespace scwx
diff --git a/scwx-qt/source/scwx/qt/types/icon_types.hpp b/scwx-qt/source/scwx/qt/types/icon_types.hpp
new file mode 100644
index 00000000..c6ae7abe
--- /dev/null
+++ b/scwx-qt/source/scwx/qt/types/icon_types.hpp
@@ -0,0 +1,46 @@
+#pragma once
+
+#include 
+
+#include 
+
+namespace scwx
+{
+namespace qt
+{
+namespace types
+{
+
+struct IconInfo
+{
+   IconInfo(const std::string& iconSheet,
+            std::size_t        iconWidth,
+            std::size_t        iconHeight,
+            std::int32_t       hotX,
+            std::int32_t       hotY) :
+       iconSheet_ {iconSheet},
+       iconWidth_ {iconWidth},
+       iconHeight_ {iconHeight},
+       hotX_ {hotX},
+       hotY_ {hotY}
+   {
+   }
+
+   void UpdateTextureInfo();
+
+   std::string             iconSheet_;
+   std::size_t             iconWidth_;
+   std::size_t             iconHeight_;
+   std::int32_t            hotX_;
+   std::int32_t            hotY_;
+   util::TextureAttributes texture_ {};
+   std::size_t             rows_ {};
+   std::size_t             columns_ {};
+   std::size_t             numIcons_ {};
+   float                   scaledWidth_ {};
+   float                   scaledHeight_ {};
+};
+
+} // namespace types
+} // namespace qt
+} // namespace scwx