diff --git a/src/config.zig b/src/config.zig
index 75dbaae02b..81d36abf2f 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -28,8 +28,10 @@ pub const RepeatableCodepointMap = Config.RepeatableCodepointMap;
 pub const RepeatableFontVariation = Config.RepeatableFontVariation;
 pub const RepeatableString = Config.RepeatableString;
 pub const RepeatablePath = Config.RepeatablePath;
+pub const SinglePath = Config.SinglePath;
 pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures;
 pub const WindowPaddingColor = Config.WindowPaddingColor;
+pub const BackgroundImageMode = Config.BackgroundImageMode;
 
 // Alternate APIs
 pub const CAPI = @import("config/CAPI.zig");
diff --git a/src/config/Config.zig b/src/config/Config.zig
index 4aba8ce322..277a181000 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -450,6 +450,34 @@ background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 },
 /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color.
 foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
 
+/// Background image for the window.
+@"background-image": SinglePath = .{},
+
+/// Background image opacity
+@"background-image-opacity": f32 = 1.0,
+
+/// Background image mode to use.
+///
+/// Valid values are:
+///
+///   * `zoomed` - Image is scaled to fit the window, preserving aspect ratio.
+///   * `stretched` - Image is stretched to fill the window, not preserving aspect ratio.
+///   * `cropped` - Image is centered in the window, preserving the aspect ratio
+///     but cropping the image to fill the window, as needed.
+///   * `tiled` - Image is repeated horizontally and vertically to fill the window.
+///   * `centered` - Image is centered in the window and displayed 1-to-1 pixel
+///     scale, preserving both the aspect ratio and the image size.
+///   * `upper-left` - Image is anchored to the upper left corner of the window,
+///     preserving the aspect ratio.
+///   * `upper-right` - Image is anchored to the upper right corner of the window,
+///     preserving the aspect ratio.
+///   * `lower-left` - Image is anchored to the lower left corner of the window,
+///     preserving the aspect ratio.
+///   * `lower-right` - Image is anchored to the lower right corner of the window,
+///     preserving the aspect ratio.
+///
+@"background-image-mode": BackgroundImageMode = .zoomed,
+
 /// The foreground and background color for selection. If this is not set, then
 /// the selection color is just the inverted window background and foreground
 /// (note: not to be confused with the cell bg/fg).
@@ -3328,7 +3356,7 @@ fn expandPaths(self: *Config, base: []const u8) !void {
 
     // Expand all of our paths
     inline for (@typeInfo(Config).Struct.fields) |field| {
-        if (field.type == RepeatablePath) {
+        if (field.type == RepeatablePath or field.type == SinglePath) {
             try @field(self, field.name).expand(
                 arena_alloc,
                 base,
@@ -3338,6 +3366,86 @@ fn expandPaths(self: *Config, base: []const u8) !void {
     }
 }
 
+/// Expand a relative path to an absolute path. This function is used by
+/// the RepeatablePath and SinglePath to expand the paths they store.
+fn expandPath(
+    alloc: Allocator,
+    base: []const u8,
+    path: []const u8,
+    diags: *cli.DiagnosticList,
+) ![]const u8 {
+    assert(std.fs.path.isAbsolute(base));
+    var dir = try std.fs.cwd().openDir(base, .{});
+    defer dir.close();
+
+    // If it is already absolute we can just return it
+    if (path.len == 0 or std.fs.path.isAbsolute(path)) return path;
+
+    // If it isn't absolute, we need to make it absolute relative
+    // to the base.
+    var buf: [std.fs.max_path_bytes]u8 = undefined;
+
+    // Check if the path starts with a tilde and expand it to the
+    // home directory on Linux/macOS. We explicitly look for "~/"
+    // because we don't support alternate users such as "~alice/"
+    if (std.mem.startsWith(u8, path, "~/")) expand: {
+        // Windows isn't supported yet
+        if (comptime builtin.os.tag == .windows) break :expand;
+
+        const expanded: []const u8 = internal_os.expandHome(
+            path,
+            &buf,
+        ) catch |err| {
+            try diags.append(alloc, .{
+                .message = try std.fmt.allocPrintZ(
+                    alloc,
+                    "error expanding home directory for path {s}: {}",
+                    .{ path, err },
+                ),
+            });
+
+            // We can't expand this path so return an empty string
+            return "";
+        };
+
+        log.debug(
+            "expanding file path from home directory: path={s}",
+            .{expanded},
+        );
+
+        return expanded;
+    }
+
+    const abs = dir.realpath(path, &buf) catch |err| abs: {
+        if (err == error.FileNotFound) {
+            // The file doesn't exist. Try to resolve the relative path
+            // another way.
+            const resolved = try std.fs.path.resolve(alloc, &.{ base, path });
+            defer alloc.free(resolved);
+            @memcpy(buf[0..resolved.len], resolved);
+            break :abs buf[0..resolved.len];
+        }
+
+        try diags.append(alloc, .{
+            .message = try std.fmt.allocPrintZ(
+                alloc,
+                "error resolving file path {s}: {}",
+                .{ path, err },
+            ),
+        });
+
+        // We can't expand this path so return an empty string
+        return "";
+    };
+
+    log.debug(
+        "expanding file path relative={s} abs={s}",
+        .{ path, abs },
+    );
+
+    return abs;
+}
+
 fn loadTheme(self: *Config, theme: Theme) !void {
     // Load the correct theme depending on the conditional state.
     // Dark/light themes were programmed prior to conditional configuration
@@ -4432,6 +4540,63 @@ pub const Palette = struct {
     }
 };
 
+/// SinglePath is a path to a single file. When loading the configuration
+/// file, always the last one will be kept and be automatically expanded
+/// relative to the path of the config file.
+pub const SinglePath = struct {
+    const Self = @This();
+
+    /// The actual value that is updated as we parse.
+    value: ?[]const u8 = null,
+
+    /// Parse a single path.
+    pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void {
+        const value = input orelse return error.ValueRequired;
+        // If the value is empty, we set the value to null
+        if (value.len == 0) {
+            self.value = null;
+            return;
+        }
+        const copy = try alloc.dupe(u8, value);
+        self.value = copy;
+    }
+
+    /// Deep copy of the struct. Required by Config.
+    pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self {
+        const value = self.value orelse return .{};
+
+        const copy_path = try alloc.dupe(u8, value);
+        return .{
+            .value = copy_path,
+        };
+    }
+
+    /// Used by Formatter
+    pub fn formatEntry(self: Self, formatter: anytype) !void {
+        const value = self.value orelse return;
+        try formatter.formatEntry([]const u8, value);
+    }
+
+    /// Expand all the paths relative to the base directory.
+    pub fn expand(
+        self: *Self,
+        alloc: Allocator,
+        base: []const u8,
+        diags: *cli.DiagnosticList,
+    ) !void {
+        // Try expanding path relative to the base.
+        const path = self.value orelse return;
+        const abs = try expandPath(alloc, base, path, diags);
+
+        if (abs.len == 0) {
+            // Blank this path so that we don't attempt to resolve it again
+            self.value = null;
+            return;
+        }
+        self.value = try alloc.dupeZ(u8, abs);
+    }
+};
+
 /// RepeatableString is a string value that can be repeated to accumulate
 /// a list of strings. This isn't called "StringList" because I find that
 /// sometimes leads to confusion that it _accepts_ a list such as
@@ -4663,88 +4828,20 @@ pub const RepeatablePath = struct {
         base: []const u8,
         diags: *cli.DiagnosticList,
     ) !void {
-        assert(std.fs.path.isAbsolute(base));
-        var dir = try std.fs.cwd().openDir(base, .{});
-        defer dir.close();
-
         for (0..self.value.items.len) |i| {
             const path = switch (self.value.items[i]) {
                 .optional, .required => |path| path,
             };
 
-            // If it is already absolute we can ignore it.
-            if (path.len == 0 or std.fs.path.isAbsolute(path)) continue;
-
-            // If it isn't absolute, we need to make it absolute relative
-            // to the base.
-            var buf: [std.fs.max_path_bytes]u8 = undefined;
-
-            // Check if the path starts with a tilde and expand it to the
-            // home directory on Linux/macOS. We explicitly look for "~/"
-            // because we don't support alternate users such as "~alice/"
-            if (std.mem.startsWith(u8, path, "~/")) expand: {
-                // Windows isn't supported yet
-                if (comptime builtin.os.tag == .windows) break :expand;
-
-                const expanded: []const u8 = internal_os.expandHome(
-                    path,
-                    &buf,
-                ) catch |err| {
-                    try diags.append(alloc, .{
-                        .message = try std.fmt.allocPrintZ(
-                            alloc,
-                            "error expanding home directory for path {s}: {}",
-                            .{ path, err },
-                        ),
-                    });
-
-                    // Blank this path so that we don't attempt to resolve it
-                    // again
-                    self.value.items[i] = .{ .required = "" };
-
-                    continue;
-                };
-
-                log.debug(
-                    "expanding file path from home directory: path={s}",
-                    .{expanded},
-                );
-
-                switch (self.value.items[i]) {
-                    .optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded),
-                }
-
-                continue;
-            }
-
-            const abs = dir.realpath(path, &buf) catch |err| abs: {
-                if (err == error.FileNotFound) {
-                    // The file doesn't exist. Try to resolve the relative path
-                    // another way.
-                    const resolved = try std.fs.path.resolve(alloc, &.{ base, path });
-                    defer alloc.free(resolved);
-                    @memcpy(buf[0..resolved.len], resolved);
-                    break :abs buf[0..resolved.len];
-                }
-
-                try diags.append(alloc, .{
-                    .message = try std.fmt.allocPrintZ(
-                        alloc,
-                        "error resolving file path {s}: {}",
-                        .{ path, err },
-                    ),
-                });
+            // Try expanding path relative to the base.
+            const abs = try expandPath(alloc, base, path, diags);
 
+            if (abs.len == 0) {
                 // Blank this path so that we don't attempt to resolve it again
                 self.value.items[i] = .{ .required = "" };
 
                 continue;
-            };
-
-            log.debug(
-                "expanding file path relative={s} abs={s}",
-                .{ path, abs },
-            );
+            }
 
             switch (self.value.items[i]) {
                 .optional, .required => |*p| p.* = try alloc.dupeZ(u8, abs),
@@ -5818,6 +5915,24 @@ pub const TextBlending = enum {
     }
 };
 
+/// See background-image-mode
+///
+/// This enum is used to set the background image mode. The shader expects
+/// a `uint`, so we use `u8` here. The values for each mode should be kept
+/// in sync with the values in the vertex shader used to render the
+/// background image (`bgimage`).
+pub const BackgroundImageMode = enum(u8) {
+    zoomed = 0,
+    stretched = 1,
+    cropped = 2,
+    tiled = 3,
+    centered = 4,
+    upper_left = 5,
+    upper_right = 6,
+    lower_left = 7,
+    lower_right = 8,
+};
+
 /// See freetype-load-flag
 pub const FreetypeLoadFlags = packed struct {
     // The defaults here at the time of writing this match the defaults
diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig
index e5dec6b2bf..9c88d2ab4d 100644
--- a/src/renderer/OpenGL.zig
+++ b/src/renderer/OpenGL.zig
@@ -4,6 +4,7 @@ pub const OpenGL = @This();
 const std = @import("std");
 const builtin = @import("builtin");
 const glfw = @import("glfw");
+const wuffs = @import("wuffs");
 const assert = std.debug.assert;
 const testing = std.testing;
 const Allocator = std.mem.Allocator;
@@ -24,6 +25,7 @@ const math = @import("../math.zig");
 const Surface = @import("../Surface.zig");
 
 const CellProgram = @import("opengl/CellProgram.zig");
+const BackgroundImageProgram = @import("opengl/BackgroundImageProgram.zig");
 const ImageProgram = @import("opengl/ImageProgram.zig");
 const gl_image = @import("opengl/image.zig");
 const custom = @import("opengl/custom.zig");
@@ -43,6 +45,9 @@ else
 const DrawMutex = if (single_threaded_draw) std.Thread.Mutex else void;
 const drawMutexZero = if (DrawMutex == void) void{} else .{};
 
+/// The maximum size of a background image.
+const max_image_size = 400 * 1024 * 1024; // 400MB
+
 alloc: std.mem.Allocator,
 
 /// The configuration we need derived from the main config.
@@ -135,6 +140,19 @@ draw_mutex: DrawMutex = drawMutexZero,
 /// terminal is in reversed mode.
 draw_background: terminal.color.RGB,
 
+/// The background image(s) to draw. Currently, we always draw the last image.
+background_image: configpkg.SinglePath,
+
+/// The opacity of the background image. Not to be confused with background-opacity
+background_image_opacity: f32,
+
+/// The background image mode to use.
+background_image_mode: configpkg.BackgroundImageMode,
+
+/// The current background image to draw. If it is null, then we will not
+/// draw any background image.
+current_background_image: ?Image = null,
+
 /// Whether we're doing padding extension for vertical sides.
 padding_extend_top: bool = true,
 padding_extend_bottom: bool = true,
@@ -183,7 +201,7 @@ const SetScreenSize = struct {
         );
 
         // Update the projection uniform within our shader
-        inline for (.{ "cell_program", "image_program" }) |name| {
+        inline for (.{ "cell_program", "image_program", "bgimage_program" }) |name| {
             const program = @field(gl_state, name);
             const bind = try program.program.use();
             defer bind.unbind();
@@ -281,6 +299,9 @@ pub const DerivedConfig = struct {
     cursor_opacity: f64,
     background: terminal.color.RGB,
     background_opacity: f64,
+    background_image: configpkg.SinglePath,
+    background_image_opacity: f32,
+    background_image_mode: configpkg.BackgroundImageMode,
     foreground: terminal.color.RGB,
     selection_background: ?terminal.color.RGB,
     selection_foreground: ?terminal.color.RGB,
@@ -302,6 +323,9 @@ pub const DerivedConfig = struct {
         // Copy our shaders
         const custom_shaders = try config.@"custom-shader".clone(alloc);
 
+        // Copy our background image
+        const background_image = try config.@"background-image".clone(alloc);
+
         // Copy our font features
         const font_features = try config.@"font-feature".clone(alloc);
 
@@ -342,6 +366,11 @@ pub const DerivedConfig = struct {
 
             .background = config.background.toTerminalRGB(),
             .foreground = config.foreground.toTerminalRGB(),
+
+            .background_image = background_image,
+            .background_image_opacity = config.@"background-image-opacity",
+            .background_image_mode = config.@"background-image-mode",
+
             .invert_selection_fg_bg = config.@"selection-invert-fg-bg",
             .bold_is_bright = config.@"bold-is-bright",
             .min_contrast = @floatCast(config.@"minimum-contrast"),
@@ -406,6 +435,9 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL {
         .default_background_color = options.config.background,
         .cursor_color = null,
         .default_cursor_color = options.config.cursor_color,
+        .background_image = options.config.background_image,
+        .background_image_opacity = options.config.background_image_opacity,
+        .background_image_mode = options.config.background_image_mode,
         .cursor_invert = options.config.cursor_invert,
         .surface_mailbox = options.surface_mailbox,
         .deferred_font_size = .{ .metrics = grid.metrics },
@@ -810,6 +842,19 @@ pub fn updateFrame(
             try self.prepKittyGraphics(state.terminal);
         }
 
+        if (self.current_background_image == null and
+            self.background_image.value != null)
+        {
+            if (single_threaded_draw) self.draw_mutex.lock();
+            defer if (single_threaded_draw) self.draw_mutex.unlock();
+            self.prepBackgroundImage() catch |err| switch (err) {
+                error.InvalidData => {
+                    log.warn("invalid image data, skipping", .{});
+                },
+                else => return err,
+            };
+        }
+
         // If we have any terminal dirty flags set then we need to rebuild
         // the entire screen. This can be optimized in the future.
         const full_rebuild: bool = rebuild: {
@@ -1201,6 +1246,82 @@ fn prepKittyImage(
     gop.value_ptr.transmit_time = image.transmit_time;
 }
 
+/// Prepares the current background image for upload
+pub fn prepBackgroundImage(self: *OpenGL) !void {
+    // If the user doesn't have a background image, do nothing...
+    const path = self.background_image.value orelse return;
+
+    // Read the file content
+    const file_content = try self.readImageContent(path);
+    defer self.alloc.free(file_content);
+
+    // Decode the png (currently, we only support png)
+    const decoded_image: wuffs.ImageData = blk: {
+        // Extract the file extension
+        const ext = std.fs.path.extension(path);
+        const ext_lower = try std.ascii.allocLowerString(self.alloc, ext);
+        defer self.alloc.free(ext_lower);
+
+        // Match based on extension
+        if (std.mem.eql(u8, ext_lower, ".png")) {
+            break :blk try wuffs.png.decode(self.alloc, file_content);
+        } else if (std.mem.eql(u8, ext_lower, ".jpg") or std.mem.eql(u8, ext_lower, ".jpeg")) {
+            break :blk try wuffs.jpeg.decode(self.alloc, file_content);
+        } else {
+            log.warn("unsupported image format: {s}", .{ext});
+            return error.InvalidData;
+        }
+    };
+    defer self.alloc.free(decoded_image.data);
+
+    // Copy the data into the pending state
+    const data = try self.alloc.dupe(u8, decoded_image.data);
+    errdefer self.alloc.free(data);
+    const pending: Image.Pending = .{
+        .width = decoded_image.width,
+        .height = decoded_image.height,
+        .data = data.ptr,
+    };
+
+    // Store the image
+    self.current_background_image = .{ .pending_rgba = pending };
+}
+
+/// Reads the content of the given image path and returns it
+pub fn readImageContent(self: *OpenGL, path: []const u8) ![]u8 {
+    assert(std.fs.path.isAbsolute(path));
+    // Open the file
+    var file = std.fs.openFileAbsolute(path, .{}) catch |err| {
+        log.warn("failed to open file: {}", .{err});
+        return error.InvalidData;
+    };
+    defer file.close();
+
+    // File must be a regular file
+    if (file.stat()) |stat| {
+        if (stat.kind != .file) {
+            log.warn("file is not a regular file kind={}", .{stat.kind});
+            return error.InvalidData;
+        }
+    } else |err| {
+        log.warn("failed to stat file: {}", .{err});
+        return error.InvalidData;
+    }
+
+    var buf_reader = std.io.bufferedReader(file.reader());
+    const reader = buf_reader.reader();
+
+    // Read the file
+    var managed = std.ArrayList(u8).init(self.alloc);
+    errdefer managed.deinit();
+    reader.readAllArrayList(&managed, max_image_size) catch |err| {
+        log.warn("failed to read file: {}", .{err});
+        return error.InvalidData;
+    };
+
+    return managed.toOwnedSlice();
+}
+
 /// rebuildCells rebuilds all the GPU cells from our CPU state. This is a
 /// slow operation but ensures that the GPU state exactly matches the CPU state.
 /// In steady-state operation, we use some GPU tricks to send down stale data
@@ -2174,6 +2295,14 @@ pub fn changeConfig(self: *OpenGL, config: *DerivedConfig) !void {
     self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null;
     self.cursor_invert = config.cursor_invert;
 
+    // Reset current background image
+    self.background_image = config.background_image;
+    self.background_image_opacity = config.background_image_opacity;
+    self.background_image_mode = config.background_image_mode;
+    if (self.current_background_image) |*img| {
+        img.markForUnload();
+    }
+
     // Update our uniforms
     self.deferred_config = .{};
 
@@ -2318,6 +2447,31 @@ pub fn drawFrame(self: *OpenGL, surface: *apprt.Surface) !void {
         }
     }
 
+    // Check if we need to update our current background image
+    if (self.current_background_image) |current_background_image| {
+        switch (current_background_image) {
+            .ready => {},
+
+            .pending_gray,
+            .pending_gray_alpha,
+            .pending_rgb,
+            .pending_rgba,
+            .replace_gray,
+            .replace_gray_alpha,
+            .replace_rgb,
+            .replace_rgba,
+            => try self.current_background_image.?.upload(self.alloc),
+
+            .unload_pending,
+            .unload_replace,
+            .unload_ready,
+            => {
+                self.current_background_image.?.deinit(self.alloc);
+                self.current_background_image = null;
+            },
+        }
+    }
+
     // In the "OpenGL Programming Guide for Mac" it explains that: "When you
     // use an NSOpenGLView object with OpenGL calls that are issued from a
     // thread other than the main one, you must set up mutex locking."
@@ -2427,6 +2581,9 @@ fn drawCellProgram(
         );
     }
 
+    // Draw our background image if defined
+    try self.drawBackgroundImage(gl_state);
+
     // Draw background images first
     try self.drawImages(
         gl_state,
@@ -2452,6 +2609,45 @@ fn drawCellProgram(
     );
 }
 
+fn drawBackgroundImage(
+    self: *OpenGL,
+    gl_state: *const GLState,
+) !void {
+    // If we don't have a background image, just return
+    const current_background_image = self.current_background_image orelse return;
+
+    // Bind our background image program
+    const bind = try gl_state.bgimage_program.bind();
+    defer bind.unbind();
+
+    // Get the texture
+    const texture = switch (current_background_image) {
+        .ready => |t| t,
+        else => {
+            return;
+        },
+    };
+
+    // Bind the texture
+    try gl.Texture.active(gl.c.GL_TEXTURE0);
+    var texbind = try texture.bind(.@"2D");
+    defer texbind.unbind();
+
+    try bind.vbo.setData(BackgroundImageProgram.Input{
+        .terminal_width = self.size.terminal().width,
+        .terminal_height = self.size.terminal().height,
+        .mode = self.background_image_mode,
+    }, .static_draw);
+    try gl_state.bgimage_program.program.setUniform("opacity", self.config.background_image_opacity);
+
+    try gl.drawElementsInstanced(
+        gl.c.GL_TRIANGLES,
+        6,
+        gl.c.GL_UNSIGNED_BYTE,
+        1,
+    );
+}
+
 /// Runs the image program to draw images.
 fn drawImages(
     self: *OpenGL,
@@ -2577,6 +2773,7 @@ fn drawCells(
 /// easy to create/destroy these as a set in situations i.e. where the
 /// OpenGL context is replaced.
 const GLState = struct {
+    bgimage_program: BackgroundImageProgram,
     cell_program: CellProgram,
     image_program: ImageProgram,
     texture: gl.Texture,
@@ -2662,6 +2859,10 @@ const GLState = struct {
             );
         }
 
+        // Build our background image renderer
+        const bgimage_program = try BackgroundImageProgram.init();
+        errdefer bgimage_program.deinit();
+
         // Build our cell renderer
         const cell_program = try CellProgram.init();
         errdefer cell_program.deinit();
@@ -2671,6 +2872,7 @@ const GLState = struct {
         errdefer image_program.deinit();
 
         return .{
+            .bgimage_program = bgimage_program,
             .cell_program = cell_program,
             .image_program = image_program,
             .texture = tex,
@@ -2683,6 +2885,7 @@ const GLState = struct {
         if (self.custom) |v| v.deinit(alloc);
         self.texture.destroy();
         self.texture_color.destroy();
+        self.bgimage_program.deinit();
         self.image_program.deinit();
         self.cell_program.deinit();
     }
diff --git a/src/renderer/opengl/BackgroundImageProgram.zig b/src/renderer/opengl/BackgroundImageProgram.zig
new file mode 100644
index 0000000000..d73c2b98ee
--- /dev/null
+++ b/src/renderer/opengl/BackgroundImageProgram.zig
@@ -0,0 +1,116 @@
+/// The OpenGL program for rendering terminal cells.
+const BackgroundImageProgram = @This();
+
+const std = @import("std");
+const gl = @import("opengl");
+const configpkg = @import("../../config.zig");
+
+pub const Input = extern struct {
+    /// vec2 terminal_size
+    terminal_width: u32 = 0,
+    terminal_height: u32 = 0,
+
+    /// uint mode
+    mode: configpkg.BackgroundImageMode = .zoomed,
+};
+
+program: gl.Program,
+vao: gl.VertexArray,
+ebo: gl.Buffer,
+vbo: gl.Buffer,
+
+pub fn init() !BackgroundImageProgram {
+    // Load and compile our shaders.
+    const program = try gl.Program.createVF(
+        @embedFile("../shaders/bgimage.v.glsl"),
+        @embedFile("../shaders/bgimage.f.glsl"),
+    );
+    errdefer program.destroy();
+
+    // Set our program uniforms
+    const pbind = try program.use();
+    defer pbind.unbind();
+
+    // Set all of our texture indexes
+    try program.setUniform("image", 0);
+
+    // Setup our VAO
+    const vao = try gl.VertexArray.create();
+    errdefer vao.destroy();
+    const vaobind = try vao.bind();
+    defer vaobind.unbind();
+
+    // Element buffer (EBO)
+    const ebo = try gl.Buffer.create();
+    errdefer ebo.destroy();
+    var ebobind = try ebo.bind(.element_array);
+    defer ebobind.unbind();
+    try ebobind.setData([6]u8{
+        0, 1, 3, // Top-left triangle
+        1, 2, 3, // Bottom-right triangle
+    }, .static_draw);
+
+    // Vertex buffer (VBO)
+    const vbo = try gl.Buffer.create();
+    errdefer vbo.destroy();
+    var vbobind = try vbo.bind(.array);
+    defer vbobind.unbind();
+    var offset: usize = 0;
+    try vbobind.attributeAdvanced(0, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset);
+    offset += 2 * @sizeOf(u32);
+    try vbobind.attributeIAdvanced(1, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(Input), offset);
+    offset += 1 * @sizeOf(u8);
+    try vbobind.enableAttribArray(0);
+    try vbobind.enableAttribArray(1);
+    try vbobind.attributeDivisor(0, 1);
+    try vbobind.attributeDivisor(1, 1);
+
+    return .{
+        .program = program,
+        .vao = vao,
+        .ebo = ebo,
+        .vbo = vbo,
+    };
+}
+
+pub fn bind(self: BackgroundImageProgram) !Binding {
+    const program = try self.program.use();
+    errdefer program.unbind();
+
+    const vao = try self.vao.bind();
+    errdefer vao.unbind();
+
+    const ebo = try self.ebo.bind(.element_array);
+    errdefer ebo.unbind();
+
+    const vbo = try self.vbo.bind(.array);
+    errdefer vbo.unbind();
+
+    return .{
+        .program = program,
+        .vao = vao,
+        .ebo = ebo,
+        .vbo = vbo,
+    };
+}
+
+pub fn deinit(self: BackgroundImageProgram) void {
+    self.ebo.destroy();
+    self.vao.destroy();
+    self.vbo.destroy();
+    self.program.destroy();
+}
+
+pub const Binding = struct {
+    program: gl.Program.Binding,
+    vao: gl.VertexArray.Binding,
+    ebo: gl.Buffer.Binding,
+    vbo: gl.Buffer.Binding,
+
+    pub fn unbind(self: Binding) void {
+        self.ebo.unbind();
+        self.vao.unbind();
+        self.vbo.unbind();
+        self.program.unbind();
+    }
+};
diff --git a/src/renderer/shaders/bgimage.f.glsl b/src/renderer/shaders/bgimage.f.glsl
new file mode 100644
index 0000000000..6598255380
--- /dev/null
+++ b/src/renderer/shaders/bgimage.f.glsl
@@ -0,0 +1,16 @@
+#version 330 core
+
+in vec2 tex_coord;
+
+layout(location = 0) out vec4 out_FragColor;
+
+uniform sampler2D image;
+uniform float opacity;
+
+void main() {
+	// Normalize the coordinates
+	vec2 norm_coord = tex_coord;
+	norm_coord = fract(tex_coord);
+	vec4 color = texture(image, norm_coord);
+	out_FragColor = vec4(color.rgb * color.a * opacity, color.a * opacity);
+}
diff --git a/src/renderer/shaders/bgimage.v.glsl b/src/renderer/shaders/bgimage.v.glsl
new file mode 100644
index 0000000000..f83096ecd1
--- /dev/null
+++ b/src/renderer/shaders/bgimage.v.glsl
@@ -0,0 +1,105 @@
+#version 330 core
+
+// These are the possible modes that "mode" can be set to.
+//
+// NOTE: this must be kept in sync with the BackgroundImageMode
+const uint MODE_ZOOMED = 0u;
+const uint MODE_STRETCHED = 1u;
+const uint MODE_CROPPED = 2u;
+const uint MODE_TILED = 3u;
+const uint MODE_CENTERED = 4u;
+const uint MODE_UPPER_LEFT = 5u;
+const uint MODE_UPPER_RIGHT = 6u;
+const uint MODE_LOWER_LEFT = 7u;
+const uint MODE_LOWER_RIGHT = 8u;
+
+layout (location = 0) in vec2 terminal_size;
+layout (location = 1) in uint mode;
+
+out vec2 tex_coord;
+
+uniform sampler2D image;
+uniform mat4 projection;
+
+void main() {
+	// Calculate the position of the image
+	vec2 position;
+	position.x = (gl_VertexID == 0 || gl_VertexID == 1) ? 1. : 0.;
+	position.y = (gl_VertexID == 0 || gl_VertexID == 3) ? 0. : 1.;
+
+	// Get the size of the image
+	vec2 image_size = textureSize(image, 0);
+
+	// Handles the scale of the image relative to the terminal size
+	vec2 scale = vec2(1.0, 1.0);
+
+	// Calculate the aspect ratio of the terminal and the image
+	vec2 aspect_ratio = vec2(
+		terminal_size.x / terminal_size.y,
+		image_size.x / image_size.y
+	);
+
+	switch (mode) {
+	case MODE_ZOOMED:
+		// If zoomed, we want to scale the image to fit the terminal
+		if (aspect_ratio.x > aspect_ratio.y) {
+			scale.x = aspect_ratio.y / aspect_ratio.x;
+		}
+		else {
+			scale.y = aspect_ratio.x / aspect_ratio.y;
+		}
+		break;
+	case MODE_CROPPED:
+		// If cropped, we want to scale the image to fit the terminal
+		if (aspect_ratio.x < aspect_ratio.y) {
+			scale.x = aspect_ratio.y / aspect_ratio.x;
+		}
+		else {
+			scale.y = aspect_ratio.x / aspect_ratio.y;
+		}
+		break;
+	case MODE_CENTERED:
+	case MODE_UPPER_LEFT:
+	case MODE_UPPER_RIGHT:
+	case MODE_LOWER_LEFT:
+	case MODE_LOWER_RIGHT:
+		// If centered, the final scale of the image should match the actual
+		// size of the image and should be centered
+		scale.x = image_size.x / terminal_size.x;
+		scale.y = image_size.y / terminal_size.y;
+		break;
+	case MODE_STRETCHED:
+	case MODE_TILED:
+		// We don't need to do anything for stretched or tiled
+		break;
+	}
+
+	vec2 final_image_size = terminal_size * position * scale;
+	vec2 offset = vec2(0.0, 0.0);
+	switch (mode) {
+	case MODE_ZOOMED:
+	case MODE_STRETCHED:
+	case MODE_CROPPED:
+	case MODE_TILED:
+	case MODE_CENTERED:
+		offset = (terminal_size * (1.0 - scale)) / 2.0;
+		break;
+	case MODE_UPPER_LEFT:
+		offset = vec2(0.0, 0.0);
+		break;
+	case MODE_UPPER_RIGHT:
+		offset = vec2(terminal_size.x - image_size.x, 0.0);
+		break;
+	case MODE_LOWER_LEFT:
+		offset = vec2(0.0, terminal_size.y - image_size.y);
+		break;
+	case MODE_LOWER_RIGHT:
+		offset = vec2(terminal_size.x - image_size.x, terminal_size.y - image_size.y);
+		break;
+	}
+	gl_Position = projection * vec4(final_image_size.xy + offset, 0.0, 1.0);
+	tex_coord = position;
+	if (mode == MODE_TILED) {
+		tex_coord = position * terminal_size / image_size;
+	}
+}