diff --git a/CMakeLists.txt b/CMakeLists.txt
index 05261370..51d6002f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -13,6 +13,7 @@ set(CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake" ${CMAKE_MODULE_PATH})
 include(${PROJECT_SOURCE_DIR}/external/cmake-conan/conan.cmake)
 
 conan_cmake_configure(REQUIRES boost/1.76.0
+                               freetype/2.10.4
                                geographiclib/1.52
                                glm/0.9.9.8
                                gtest/cci.20210126
diff --git a/scwx-qt/gl/text.frag b/scwx-qt/gl/text.frag
new file mode 100644
index 00000000..23298e8e
--- /dev/null
+++ b/scwx-qt/gl/text.frag
@@ -0,0 +1,12 @@
+#version 330 core
+in vec2 texCoords;
+out vec4 color;
+
+uniform sampler2D text;
+uniform vec4 textColor;
+
+void main()
+{
+   vec4 sampled = vec4(1.0f, 1.0f, 1.0f, texture(text, texCoords).r);
+   color        = textColor * sampled;
+}
diff --git a/scwx-qt/gl/text.vert b/scwx-qt/gl/text.vert
new file mode 100644
index 00000000..ffc3a8be
--- /dev/null
+++ b/scwx-qt/gl/text.vert
@@ -0,0 +1,11 @@
+#version 330 core
+layout (location = 0) in vec4 vertex;
+out vec2 texCoords;
+
+uniform mat4 projection;
+
+void main()
+{
+   gl_Position = projection * vec4(vertex.xy, 0.0f, 1.0f);
+   texCoords   = vertex.zw;
+}
diff --git a/scwx-qt/scwx-qt.cmake b/scwx-qt/scwx-qt.cmake
index 6ab4d10c..8c28f3e0 100644
--- a/scwx-qt/scwx-qt.cmake
+++ b/scwx-qt/scwx-qt.cmake
@@ -12,6 +12,7 @@ set(CMAKE_CXX_STANDARD 17)
 set(CMAKE_CXX_STANDARD_REQUIRED ON)
 
 find_package(Boost)
+find_package(Freetype)
 find_package(geographiclib)
 find_package(glm)
 
@@ -51,6 +52,8 @@ set(HDR_MAIN source/scwx/qt/main/main_window.hpp)
 set(SRC_MAIN source/scwx/qt/main/main.cpp
              source/scwx/qt/main/main_window.cpp)
 set(UI_MAIN  source/scwx/qt/main/main_window.ui)
+set(HDR_GL source/scwx/qt/gl/text_shader.hpp)
+set(SRC_GL source/scwx/qt/gl/text_shader.cpp)
 set(HDR_MANAGER source/scwx/qt/manager/radar_manager.hpp)
 set(SRC_MANAGER source/scwx/qt/manager/radar_manager.cpp)
 set(HDR_MAP source/scwx/qt/map/map_widget.hpp
@@ -61,16 +64,20 @@ set(SRC_MAP source/scwx/qt/map/map_widget.cpp
             source/scwx/qt/map/radar_layer.cpp
             source/scwx/qt/map/radar_range_layer.cpp
             source/scwx/qt/map/triangle_layer.cpp)
-set(HDR_UTIL source/scwx/qt/util/gl.hpp
+set(HDR_UTIL source/scwx/qt/util/font.hpp
+             source/scwx/qt/util/gl.hpp
              source/scwx/qt/util/shader_program.hpp)
-set(SRC_UTIL source/scwx/qt/util/shader_program.cpp)
+set(SRC_UTIL source/scwx/qt/util/font.cpp
+             source/scwx/qt/util/shader_program.cpp)
 set(HDR_VIEW source/scwx/qt/view/radar_view.hpp)
 set(SRC_VIEW source/scwx/qt/view/radar_view.cpp)
 
 set(RESOURCE_FILES scwx-qt.qrc)
 
 set(SHADER_FILES gl/radar.frag
-                 gl/radar.vert)
+                 gl/radar.vert
+                 gl/text.frag
+                 gl/text.vert)
 
 set(TS_FILES ts/scwx_en_US.ts)
 
@@ -122,6 +129,7 @@ target_link_libraries(scwx-qt PRIVATE Qt${QT_VERSION_MAJOR}::Widgets
                                       Boost::timer
                                       qmapboxgl
                                       opengl32
+                                      Freetype::Freetype
                                       GeographicLib::GeographicLib
                                       glm::glm
                                       wxdata)
diff --git a/scwx-qt/scwx-qt.qrc b/scwx-qt/scwx-qt.qrc
index 39f00ae6..2638956d 100644
--- a/scwx-qt/scwx-qt.qrc
+++ b/scwx-qt/scwx-qt.qrc
@@ -2,5 +2,7 @@
     
         gl/radar.frag
         gl/radar.vert
+        gl/text.frag
+        gl/text.vert
     
 
diff --git a/scwx-qt/source/scwx/qt/gl/text_shader.cpp b/scwx-qt/source/scwx/qt/gl/text_shader.cpp
new file mode 100644
index 00000000..c4ad61a5
--- /dev/null
+++ b/scwx-qt/source/scwx/qt/gl/text_shader.cpp
@@ -0,0 +1,155 @@
+#include 
+
+#include 
+#include 
+
+namespace scwx
+{
+namespace qt
+{
+namespace gl
+{
+
+static const std::string logPrefix_ = "[scwx::qt::gl::text_shader] ";
+
+class TextShaderImpl
+{
+public:
+   explicit TextShaderImpl(OpenGLFunctions& gl) :
+       gl_ {gl},
+       projectionLocation_(GL_INVALID_INDEX),
+       textColorLocation_(GL_INVALID_INDEX),
+       vao_ {GL_INVALID_INDEX},
+       vbo_ {GL_INVALID_INDEX}
+   {
+   }
+
+   ~TextShaderImpl() {}
+
+   OpenGLFunctions& gl_;
+
+   GLint projectionLocation_;
+   GLint textColorLocation_;
+
+   GLuint vao_;
+   GLuint vbo_;
+};
+
+TextShader::TextShader(OpenGLFunctions& gl) :
+    ShaderProgram(gl), p(std::make_unique(gl))
+{
+}
+TextShader::~TextShader() = default;
+
+TextShader::TextShader(TextShader&&) noexcept = default;
+TextShader& TextShader::operator=(TextShader&&) noexcept = default;
+
+bool TextShader::Initialize()
+{
+   OpenGLFunctions& gl = p->gl_;
+
+   // Load and configure shader
+   bool success = Load(":/gl/text.vert", ":/gl/text.frag");
+
+   p->projectionLocation_ = gl.glGetUniformLocation(id(), "projection");
+   if (p->projectionLocation_ == -1)
+   {
+      BOOST_LOG_TRIVIAL(warning) << logPrefix_ << "Could not find projection";
+   }
+
+   p->textColorLocation_ = gl.glGetUniformLocation(id(), "textColor");
+   if (p->textColorLocation_ == -1)
+   {
+      BOOST_LOG_TRIVIAL(warning) << logPrefix_ << "Could not find textColor";
+   }
+
+   gl.glGenVertexArrays(1, &p->vao_);
+   gl.glGenBuffers(1, &p->vbo_);
+   gl.glBindVertexArray(p->vao_);
+   gl.glBindBuffer(GL_ARRAY_BUFFER, p->vbo_);
+   gl.glBufferData(
+      GL_ARRAY_BUFFER, sizeof(float) * 6 * 4, nullptr, GL_DYNAMIC_DRAW);
+   gl.glEnableVertexAttribArray(0);
+   gl.glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(float), 0);
+   gl.glBindBuffer(GL_ARRAY_BUFFER, 0);
+   gl.glBindVertexArray(0);
+
+   return success;
+}
+
+void TextShader::RenderText(const std::string&               text,
+                            float                            x,
+                            float                            y,
+                            float                            scale,
+                            const glm::mat4&                 projection,
+                            const boost::gil::rgba8_pixel_t& color,
+                            const std::unordered_map& glyphs)
+{
+   OpenGLFunctions& gl = p->gl_;
+
+   Use();
+
+   gl.glEnable(GL_BLEND);
+   gl.glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
+
+   gl.glUniformMatrix4fv(
+      p->projectionLocation_, 1, GL_FALSE, glm::value_ptr(projection));
+   gl.glUniform4f(
+      p->textColorLocation_, color[0], color[1], color[2], color[3]);
+
+   gl.glActiveTexture(GL_TEXTURE0);
+   gl.glBindVertexArray(p->vao_);
+
+   for (auto c = text.cbegin(); c != text.cend(); c++)
+   {
+      if (glyphs.find(*c) == glyphs.end())
+      {
+         continue;
+      }
+
+      const util::Glyph& g = glyphs.at(*c);
+
+      float xpos = x + g.bearing.x * scale;
+      float ypos = y - (g.size.y - g.bearing.y) * scale;
+
+      float w = g.size.x * scale;
+      float h = g.size.y * scale;
+
+      // Glyph vertices
+      float vertices[6][4] = {{xpos, ypos + h, 0.0f, 0.0f},
+                              {xpos, ypos, 0.0f, 1.0f},
+                              {xpos + w, ypos, 1.0f, 1.0f}, //
+                                                            //
+                              {xpos, ypos + h, 0.0f, 0.0f},
+                              {xpos + w, ypos, 1.0f, 1.0f},
+                              {xpos + w, ypos + h, 1.0f, 0.0f}};
+
+      // Render glyph texture
+      gl.glBindTexture(GL_TEXTURE_2D, g.textureId);
+
+      gl.glBindBuffer(GL_ARRAY_BUFFER, p->vbo_);
+      gl.glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(vertices), vertices);
+      gl.glBindBuffer(GL_ARRAY_BUFFER, 0);
+
+      gl.glDrawArrays(GL_TRIANGLES, 0, 6);
+
+      // Advance to the next glyph
+      x += (g.advance >> 6) * scale;
+   }
+}
+
+void TextShader::SetProjection(const glm::mat4& projection)
+{
+   p->gl_.glUniformMatrix4fv(
+      p->projectionLocation_, 1, GL_FALSE, glm::value_ptr(projection));
+}
+
+void TextShader::SetTextColor(const boost::gil::rgba8_pixel_t color)
+{
+   p->gl_.glUniform4f(
+      p->textColorLocation_, color[0], color[1], color[2], color[3]);
+}
+
+} // namespace gl
+} // namespace qt
+} // namespace scwx
diff --git a/scwx-qt/source/scwx/qt/gl/text_shader.hpp b/scwx-qt/source/scwx/qt/gl/text_shader.hpp
new file mode 100644
index 00000000..0e3c5090
--- /dev/null
+++ b/scwx-qt/source/scwx/qt/gl/text_shader.hpp
@@ -0,0 +1,49 @@
+#pragma once
+
+#include 
+#include 
+
+#include 
+
+#include 
+#include 
+
+namespace scwx
+{
+namespace qt
+{
+namespace gl
+{
+
+class TextShaderImpl;
+
+class TextShader : public ShaderProgram
+{
+public:
+   explicit TextShader(OpenGLFunctions& gl);
+   ~TextShader();
+
+   TextShader(const TextShader&) = delete;
+   TextShader& operator=(const TextShader&) = delete;
+
+   TextShader(TextShader&&) noexcept;
+   TextShader& operator=(TextShader&&) noexcept;
+
+   bool Initialize();
+   void RenderText(const std::string&                           text,
+                   float                                        x,
+                   float                                        y,
+                   float                                        scale,
+                   const glm::mat4&                             projection,
+                   const boost::gil::rgba8_pixel_t&             color,
+                   const std::unordered_map& glyphs);
+   void SetProjection(const glm::mat4& projection);
+   void SetTextColor(const boost::gil::rgba8_pixel_t color);
+
+private:
+   std::unique_ptr p;
+};
+
+} // namespace gl
+} // namespace qt
+} // namespace scwx
diff --git a/scwx-qt/source/scwx/qt/util/font.cpp b/scwx-qt/source/scwx/qt/util/font.cpp
new file mode 100644
index 00000000..e5ce57c1
--- /dev/null
+++ b/scwx-qt/source/scwx/qt/util/font.cpp
@@ -0,0 +1,179 @@
+#include 
+
+#include 
+#include 
+
+#include 
+#include 
+#include 
+
+#include FT_FREETYPE_H
+
+namespace scwx
+{
+namespace qt
+{
+namespace util
+{
+
+static const std::string logPrefix_ = "[scwx::qt::util::font] ";
+
+static std::unordered_map> fontMap_;
+
+static FT_Library ft_ {nullptr};
+static std::mutex ftMutex_;
+
+static bool InitializeFreeType();
+
+class FontImpl
+{
+public:
+   explicit FontImpl(const std::string& resource) :
+       resource_(resource), fontData_(), face_ {nullptr}
+   {
+   }
+
+   ~FontImpl() {}
+
+   const std::string resource_;
+
+   QByteArray fontData_;
+   FT_Face    face_;
+};
+
+Font::Font(const std::string& resource) :
+    p(std::make_unique(resource))
+{
+}
+Font::~Font()
+{
+   FT_Done_Face(p->face_);
+}
+
+void Font::GenerateGlyphs(OpenGLFunctions&                 gl,
+                          std::unordered_map& glyphs,
+                          unsigned int                     height)
+{
+   FT_Error error;
+   FT_Face& face = p->face_;
+
+   // Allow single-byte texture colors
+   gl.glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
+
+   FT_Set_Pixel_Sizes(p->face_, 0, 48);
+
+   for (unsigned char c = 0; c < 128; c++)
+   {
+      if (glyphs.find(c) != glyphs.end())
+      {
+         BOOST_LOG_TRIVIAL(warning) << logPrefix_ << "Found glyph "
+                                    << static_cast(c) << ", skipping";
+         continue;
+      }
+
+      if ((error = FT_Load_Char(face, c, FT_LOAD_RENDER)) != 0)
+      {
+         BOOST_LOG_TRIVIAL(error) << logPrefix_ << "Failed to load glyph "
+                                  << static_cast(c) << ": " << error;
+         continue;
+      }
+
+      GLuint texture;
+      gl.glGenTextures(1, &texture);
+      gl.glBindTexture(GL_TEXTURE_2D, texture);
+
+      gl.glTexImage2D(GL_TEXTURE_2D,
+                      0,
+                      GL_RED,
+                      face->glyph->bitmap.width,
+                      face->glyph->bitmap.rows,
+                      0,
+                      GL_RED,
+                      GL_UNSIGNED_BYTE,
+                      face->glyph->bitmap.buffer);
+
+      gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+      gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+      gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+      gl.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+
+      glyphs.insert(
+         {c,
+          Glyph {
+             texture,
+             glm::ivec2(face->glyph->bitmap.width, face->glyph->bitmap.rows),
+             glm::ivec2(face->glyph->bitmap_left, face->glyph->bitmap_top),
+             face->glyph->advance.x}});
+   }
+}
+
+std::shared_ptr Font::Create(const std::string& resource)
+{
+   std::shared_ptr font = nullptr;
+   FT_Error              error;
+
+   if (!InitializeFreeType())
+   {
+      return font;
+   }
+
+   auto it = fontMap_.find(resource);
+   if (it != fontMap_.end())
+   {
+      return it->second;
+   }
+
+   QFile fontFile(resource.c_str());
+   fontFile.open(QIODevice::ReadOnly);
+   if (!fontFile.isOpen())
+   {
+      BOOST_LOG_TRIVIAL(error)
+         << logPrefix_ << "Could not read font file: " << resource;
+      return font;
+   }
+
+   font               = std::make_shared(resource);
+   font->p->fontData_ = fontFile.readAll();
+
+   {
+      std::scoped_lock(ftMutex_);
+      if ((error = FT_New_Memory_Face(
+              ft_,
+              reinterpret_cast(font->p->fontData_.data()),
+              font->p->fontData_.size(),
+              0,
+              &font->p->face_)) != 0)
+      {
+         BOOST_LOG_TRIVIAL(error)
+            << logPrefix_ << "Failed to load font: " << error;
+         font.reset();
+      }
+   }
+
+   if (font != nullptr)
+   {
+      fontMap_.insert({resource, font});
+   }
+
+   return font;
+}
+
+static bool InitializeFreeType()
+{
+   std::scoped_lock(ftMutex_);
+
+   FT_Error error;
+
+   if (ft_ == nullptr && (error = FT_Init_FreeType(&ft_)) != 0)
+   {
+      BOOST_LOG_TRIVIAL(error)
+         << logPrefix_ << "Could not init FreeType library: " << error;
+      ft_ = nullptr;
+   }
+
+   return (ft_ != nullptr);
+}
+
+} // namespace util
+} // namespace qt
+} // namespace scwx
diff --git a/scwx-qt/source/scwx/qt/util/font.hpp b/scwx-qt/source/scwx/qt/util/font.hpp
new file mode 100644
index 00000000..0c123e79
--- /dev/null
+++ b/scwx-qt/source/scwx/qt/util/font.hpp
@@ -0,0 +1,51 @@
+#pragma once
+
+#include 
+
+#include 
+#include 
+
+#include 
+
+namespace scwx
+{
+namespace qt
+{
+namespace util
+{
+
+struct Glyph
+{
+   GLuint     textureId;
+   glm::ivec2 size;    // pixels
+   glm::ivec2 bearing; // pixels
+   GLint      advance; // 1/64 pixels
+};
+
+class FontImpl;
+
+class Font
+{
+public:
+   explicit Font(const std::string& resource);
+   ~Font();
+
+   Font(const Font&) = delete;
+   Font& operator=(const Font&) = delete;
+
+   Font(Font&&)  = delete;
+   Font& operator=(Font&&) = delete;
+
+   void GenerateGlyphs(OpenGLFunctions&                 gl,
+                       std::unordered_map& glyphs,
+                       unsigned int                     height);
+
+   static std::shared_ptr Create(const std::string& resource);
+
+private:
+   std::unique_ptr p;
+};
+
+} // namespace util
+} // namespace qt
+} // namespace scwx
diff --git a/scwx-qt/source/scwx/qt/util/shader_program.cpp b/scwx-qt/source/scwx/qt/util/shader_program.cpp
index 1b707360..a69578c1 100644
--- a/scwx-qt/source/scwx/qt/util/shader_program.cpp
+++ b/scwx-qt/source/scwx/qt/util/shader_program.cpp
@@ -147,7 +147,7 @@ bool ShaderProgram::Load(const std::string& vertexPath,
    gl.glDeleteShader(vertexShader);
    gl.glDeleteShader(fragmentShader);
 
-   return false;
+   return success;
 }
 
 void ShaderProgram::Use() const
diff --git a/scwx-qt/source/scwx/qt/util/shader_program.hpp b/scwx-qt/source/scwx/qt/util/shader_program.hpp
index a10a6f12..b05524ca 100644
--- a/scwx-qt/source/scwx/qt/util/shader_program.hpp
+++ b/scwx-qt/source/scwx/qt/util/shader_program.hpp
@@ -20,7 +20,7 @@ class ShaderProgram
 {
 public:
    explicit ShaderProgram(OpenGLFunctions& gl);
-   ~ShaderProgram();
+   virtual ~ShaderProgram();
 
    ShaderProgram(const ShaderProgram&) = delete;
    ShaderProgram& operator=(const ShaderProgram&) = delete;