diff --git a/es-core/src/ThemeData.cpp b/es-core/src/ThemeData.cpp
index 565d0b8c8..f109c53ba 100644
--- a/es-core/src/ThemeData.cpp
+++ b/es-core/src/ThemeData.cpp
@@ -131,6 +131,7 @@ std::map<std::string, std::map<std::string, ThemeData::ElementPropertyType>>
        {"speed", FLOAT},
        {"direction", STRING},
        {"keepAspectRatio", BOOLEAN},
+       {"interpolation", STRING},
        {"opacity", FLOAT},
        {"visible", BOOLEAN},
        {"zIndex", FLOAT}}},
diff --git a/es-core/src/components/GIFAnimComponent.cpp b/es-core/src/components/GIFAnimComponent.cpp
index 2d8fdd0ac..7723ea1dd 100644
--- a/es-core/src/components/GIFAnimComponent.cpp
+++ b/es-core/src/components/GIFAnimComponent.cpp
@@ -6,14 +6,470 @@
 //  Component to play GIF animations.
 //
 
+#define DEBUG_ANIMATION false
+
 #include "components/GIFAnimComponent.h"
 
+#include "Log.h"
+#include "ThemeData.h"
+#include "Window.h"
+#include "resources/ResourceManager.h"
+
 GIFAnimComponent::GIFAnimComponent()
+    : mFrameSize {0}
+    , mAnimation {nullptr}
+    , mFrame {nullptr}
+    , mStartDirection {"normal"}
+    , mTotalFrames {0}
+    , mFrameNum {0}
+    , mFrameTime {0}
+    , mFileWidth {0}
+    , mFileHeight {0}
+    , mFrameRate {0.0}
+    , mSpeedModifier {1.0f}
+    , mTargetPacing {0}
+    , mTimeAccumulator {0}
+    , mLastRenderedFrame {-1}
+    , mSkippedFrames {0}
+    , mHoldFrame {false}
+    , mPause {false}
+    , mExternalPause {false}
+    , mAlternate {false}
+    , mKeepAspectRatio {true}
 {
-    //
+    // Get an empty texture for rendering the animation.
+    mTexture = TextureResource::get("");
+#if defined(USE_OPENGLES_10) || defined(USE_OPENGLES_20)
+    // This is not really supported by the OpenGL ES standard so hopefully it works
+    // with all drivers and on all operating systems.
+    mTexture->setFormat(Renderer::Texture::BGRA);
+#endif
+
+    // Set component defaults.
+    setOrigin(0.5f, 0.5f);
+    setSize(Renderer::getScreenWidth() * 0.2f, Renderer::getScreenHeight() * 0.2f);
+    setPosition(Renderer::getScreenWidth() * 0.3f, Renderer::getScreenHeight() * 0.3f);
+    setDefaultZIndex(10.0f);
+    setZIndex(10.0f);
+    mTexture->setLinearMagnify(false);
 }
 
-GIFAnimComponent::~GIFAnimComponent()
+void GIFAnimComponent::setAnimation(const std::string& path)
 {
-    //
+    if (mAnimation != nullptr) {
+        FreeImage_CloseMultiBitmap(mAnimation, 0);
+        mAnimation = nullptr;
+        mPictureRGBA.clear();
+        mLastRenderedFrame = -1;
+        mFileWidth = 0;
+        mFileHeight = 0;
+    }
+
+    mPath = path;
+
+    if (mPath.empty()) {
+        LOG(LogError) << "Path to GIF animation is empty";
+        return;
+    }
+
+    if (mPath.front() == ':')
+        mPath = ResourceManager::getInstance().getResourcePath(mPath);
+    else
+        mPath = Utils::FileSystem::expandHomePath(mPath);
+
+    if (!(Utils::FileSystem::isRegularFile(mPath) || Utils::FileSystem::isSymlink(mPath))) {
+        LOG(LogError) << "Couldn't open GIF animation file \"" << mPath << "\"";
+        return;
+    }
+
+    FREE_IMAGE_FORMAT fileFormat;
+
+#if defined(_WIN64)
+    fileFormat = FreeImage_GetFileTypeU(Utils::String::stringToWideString(mPath).c_str());
+#else
+    fileFormat = FreeImage_GetFileType(mPath.c_str());
+#endif
+
+    if (fileFormat == FIF_UNKNOWN)
+#if defined(_WIN64)
+        fileFormat =
+            FreeImage_GetFIFFromFilenameU(Utils::String::stringToWideString(mPath).c_str());
+#else
+        fileFormat = FreeImage_GetFIFFromFilename(mPath.c_str());
+#endif
+
+    if (fileFormat != FIF_GIF) {
+        LOG(LogError)
+            << "GIFAnimComponent::setAnimation(): Image not recognized as being in GIF format";
+        return;
+    }
+
+    // Make sure that we can actually read this format.
+    if (FreeImage_FIFSupportsReading(fileFormat)) {
+        mAnimation =
+            FreeImage_OpenMultiBitmap(fileFormat, mPath.c_str(), false, true, false, GIF_PLAYBACK);
+
+        mFrame = FreeImage_LockPage(mAnimation, 0);
+        FITAG* tagFrameTime {nullptr};
+
+        FreeImage_GetMetadata(FIMD_ANIMATION, mFrame, "FrameTime", &tagFrameTime);
+        if (tagFrameTime != nullptr) {
+            if (FreeImage_GetTagCount(tagFrameTime) == 1) {
+                const uint32_t frameTime {
+                    *static_cast<const uint32_t*>(FreeImage_GetTagValue(tagFrameTime))};
+                if (frameTime >= 20 && frameTime <= 1000)
+                    mFrameTime = frameTime;
+            }
+        }
+    }
+    else {
+        LOG(LogError) << "GIFAnimComponent::setAnimation(): Couldn't process file \"" << mPath
+                      << "\"";
+        return;
+    }
+
+    if (!mAnimation) {
+        LOG(LogError) << "GIFAnimComponent::setAnimation(): Couldn't load animation file \""
+                      << mPath << "\"";
+        return;
+    }
+
+    if (!mKeepAspectRatio && (mSize.x == 0.0f || mSize.y == 0.0f)) {
+        LOG(LogWarning) << "GIFAnimComponent: Width or height auto sizing is incompatible with "
+                           "disabling of <keepAspectRatio> so ignoring this setting";
+    }
+
+    size_t width {0};
+    size_t height {0};
+
+    unsigned int filePitch {0};
+
+    mTotalFrames = static_cast<size_t>(FreeImage_GetPageCount(mAnimation));
+
+    mFileWidth = FreeImage_GetWidth(mFrame);
+    mFileHeight = FreeImage_GetHeight(mFrame);
+    filePitch = FreeImage_GetPitch(mFrame);
+
+    if (mSize.x == 0.0f || mSize.y == 0.0f) {
+        double sizeRatio {static_cast<double>(mFileWidth) / static_cast<double>(mFileHeight)};
+
+        if (mSize.x == 0) {
+            width = static_cast<size_t>(static_cast<double>(mSize.y) * sizeRatio);
+            height = static_cast<size_t>(mSize.y);
+        }
+        else {
+            width = static_cast<size_t>(mSize.x);
+            height = static_cast<size_t>(static_cast<double>(mSize.x) / sizeRatio);
+        }
+    }
+    else {
+        width = static_cast<size_t>(mSize.x);
+        height = static_cast<size_t>(mSize.y);
+    }
+
+    mSize.x = static_cast<float>(width);
+    mSize.y = static_cast<float>(height);
+
+    mPictureRGBA.resize(mFileWidth * mFileHeight * 4);
+
+    FreeImage_ConvertToRawBits(reinterpret_cast<BYTE*>(&mPictureRGBA.at(0)), mFrame, filePitch, 32,
+                               FI_RGBA_RED, FI_RGBA_GREEN, FI_RGBA_BLUE, 1);
+
+    mTexture->initFromPixels(&mPictureRGBA.at(0), mFileWidth, mFileHeight);
+
+    FreeImage_UnlockPage(mAnimation, mFrame, false);
+    FreeImage_CloseMultiBitmap(mAnimation, 0);
+
+    mDirection = mStartDirection;
+    mFrameRate = 1000.0 / static_cast<double>(mFrameTime);
+    mFrameSize = mFileWidth * mFileHeight * 4;
+    mTargetPacing = static_cast<int>((1000.0 / mFrameRate) / static_cast<double>(mSpeedModifier));
+    int duration {mTargetPacing * mTotalFrames};
+
+    if (mDirection == "reverse")
+        mFrameNum = mTotalFrames - 1;
+
+    if (DEBUG_ANIMATION) {
+        LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Width: " << mFileWidth;
+        LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Height: " << mFileHeight;
+        LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Total number of frames: "
+                      << mTotalFrames;
+        LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Frame rate: " << mFrameRate;
+        LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Speed modifier: " << mSpeedModifier;
+        // This figure does not double if direction has been set to alternate or alternateReverse,
+        // it only tells the duration of a single playthrough of all frames.
+        LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Target duration: " << duration << " ms";
+        LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Frame size: " << mFrameSize
+                      << " bytes (" << std::fixed << std::setprecision(1)
+                      << static_cast<double>(mFrameSize) / 1024.0 / 1024.0 << " MiB)";
+        LOG(LogDebug) << "GIFAnimComponent::setAnimation(): Animation size: "
+                      << mFrameSize * mTotalFrames << " bytes (" << std::fixed
+                      << std::setprecision(1)
+                      << static_cast<double>(mFrameSize * mTotalFrames) / 1024.0 / 1024.0
+                      << " MiB)";
+    }
+
+    mAnimationStartTime = std::chrono::system_clock::now();
+}
+
+void GIFAnimComponent::resetFileAnimation()
+{
+    mExternalPause = false;
+    mTimeAccumulator = 0;
+    mFrameNum = mStartDirection == "reverse" ? mTotalFrames - 1 : 0;
+
+    if (mAnimation != nullptr)
+        mLastRenderedFrame = static_cast<int>(mFrameNum);
+}
+
+void GIFAnimComponent::onSizeChanged()
+{
+    // Setting the animation again will completely reinitialize it.
+    if (mPath != "")
+        setAnimation(mPath);
+}
+
+void GIFAnimComponent::applyTheme(const std::shared_ptr<ThemeData>& theme,
+                                  const std::string& view,
+                                  const std::string& element,
+                                  unsigned int properties)
+{
+    using namespace ThemeFlags;
+    const ThemeData::ThemeElement* elem {theme->getElement(view, element, "animation")};
+
+    if (elem->has("size")) {
+        glm::vec2 size = elem->get<glm::vec2>("size");
+        if (size.x == 0.0f && size.y == 0.0f) {
+            LOG(LogWarning) << "GIFAnimComponent: Invalid theme configuration, <size> defined as \""
+                            << size.x << " " << size.y << "\"";
+            return;
+        }
+    }
+
+    if (elem->has("speed")) {
+        const float speed {elem->get<float>("speed")};
+        if (speed < 0.2f || speed > 3.0f) {
+            LOG(LogWarning)
+                << "GIFAnimComponent: Invalid theme configuration, <speed> defined as \""
+                << std::fixed << std::setprecision(1) << speed << "\"";
+        }
+        else {
+            mSpeedModifier = speed;
+        }
+    }
+
+    if (elem->has("keepAspectRatio"))
+        mKeepAspectRatio = elem->get<bool>("keepAspectRatio");
+
+    if (elem->has("direction")) {
+        std::string direction = elem->get<std::string>("direction");
+        if (direction == "normal") {
+            mStartDirection = "normal";
+            mAlternate = false;
+        }
+        else if (direction == "reverse") {
+            mStartDirection = "reverse";
+            mAlternate = false;
+        }
+        else if (direction == "alternate") {
+            mStartDirection = "normal";
+            mAlternate = true;
+        }
+        else if (direction == "alternateReverse") {
+            mStartDirection = "reverse";
+            mAlternate = true;
+        }
+        else {
+            LOG(LogWarning)
+                << "GIFAnimComponent: Invalid theme configuration, <direction> defined as \""
+                << direction << "\"";
+            mStartDirection = "normal";
+            mAlternate = false;
+        }
+    }
+
+    if (elem->has("interpolation")) {
+        const std::string interpolation {elem->get<std::string>("interpolation")};
+        if (interpolation == "linear") {
+            mTexture->setLinearMagnify(true);
+        }
+        else if (interpolation == "nearest") {
+            mTexture->setLinearMagnify(false);
+        }
+        else {
+            mTexture->setLinearMagnify(false);
+            LOG(LogWarning)
+                << "GIFAnimComponent::applyTheme(): Invalid theme configuration, property "
+                   "<interpolation> defined as \""
+                << interpolation << "\"";
+        }
+    }
+
+    GuiComponent::applyTheme(theme, view, element, properties);
+
+    if (elem->has("path")) {
+        std::string path {elem->get<std::string>("path")};
+        if (path != "") {
+            setAnimation(path);
+        }
+    }
+    else {
+        LOG(LogWarning) << "GIFAnimComponent: Invalid theme configuration, <path> not set";
+        return;
+    }
+}
+
+void GIFAnimComponent::update(int deltaTime)
+{
+    if (!isVisible() || mThemeOpacity == 0.0f || mAnimation == nullptr)
+        return;
+
+    if (mWindow->getAllowFileAnimation()) {
+        mPause = false;
+    }
+    else {
+        mPause = true;
+        mTimeAccumulator = 0;
+        return;
+    }
+
+    // If the time accumulator value is really high something must have happened such as the
+    // application having been suspended. Reset it to zero in this case as it would otherwise
+    // never recover.
+    if (mTimeAccumulator > deltaTime * 200)
+        mTimeAccumulator = 0;
+
+    // Prevent animation from playing too quickly.
+    if (mTimeAccumulator + deltaTime < mTargetPacing) {
+        mHoldFrame = true;
+        mTimeAccumulator += deltaTime;
+    }
+    else {
+        mHoldFrame = false;
+        mTimeAccumulator = mTimeAccumulator - mTargetPacing + deltaTime;
+    }
+
+    // Rudimentary frame skipping logic, not entirely accurate but probably good enough.
+    while (mTimeAccumulator - deltaTime > mTargetPacing) {
+        if (DEBUG_ANIMATION && 0) {
+            LOG(LogDebug) << "GIFAnimComponent::update(): Skipped Frame, mTimeAccumulator / "
+                             "mTargetPacing: "
+                          << mTimeAccumulator - deltaTime << " / " << mTargetPacing;
+        }
+
+        if (mDirection == "reverse")
+            --mFrameNum;
+        else
+            ++mFrameNum;
+
+        ++mSkippedFrames;
+        mTimeAccumulator -= mTargetPacing;
+    }
+}
+
+void GIFAnimComponent::render(const glm::mat4& parentTrans)
+{
+    if (!isVisible() || mThemeOpacity == 0.0f || mAnimation == nullptr)
+        return;
+
+    glm::mat4 trans {parentTrans * getTransform()};
+
+    // This is necessary as there may otherwise be no texture to render when paused.
+    if ((mExternalPause || mPause) && mTexture->getSize().x == 0.0f) {
+        mTexture->initFromPixels(&mPictureRGBA.at(0), static_cast<size_t>(mSize.x),
+                                 static_cast<size_t>(mSize.y));
+    }
+
+    bool doRender {true};
+
+    // Don't render if a menu is open except if the cached background is getting invalidated.
+    if (mWindow->getGuiStackSize() > 1 && !mWindow->isInvalidatingCachedBackground())
+        doRender = false;
+
+    // Don't render any new frames if paused or if a menu is open (unless invalidating background).
+    if ((!mPause && !mExternalPause) && doRender) {
+        if ((mDirection == "normal" && mFrameNum >= mTotalFrames) ||
+            (mDirection == "reverse" && mFrameNum < 0)) {
+            if (DEBUG_ANIMATION) {
+                LOG(LogDebug) << "GIFAnimComponent::render(): Skipped frames: " << mSkippedFrames;
+                LOG(LogDebug) << "GIFAnimComponent::render(): Actual duration: "
+                              << std::chrono::duration_cast<std::chrono::milliseconds>(
+                                     std::chrono::system_clock::now() - mAnimationStartTime)
+                                     .count()
+                              << " ms";
+            }
+
+            if (mAlternate) {
+                if (mDirection == "normal")
+                    mDirection = "reverse";
+                else
+                    mDirection = "normal";
+            }
+
+            mTimeAccumulator = 0;
+            mSkippedFrames = 0;
+
+            if (mDirection == "reverse" && mAlternate)
+                mFrameNum = mTotalFrames - 2;
+            else if (mDirection == "reverse" && !mAlternate)
+                mFrameNum = mTotalFrames - 1;
+            else if (mDirection == "normal" && mAlternate)
+                mFrameNum = 1;
+            else
+                mFrameNum = 0;
+
+            if (DEBUG_ANIMATION)
+                mAnimationStartTime = std::chrono::system_clock::now();
+        }
+
+        if (!mHoldFrame) {
+            mAnimation =
+                FreeImage_OpenMultiBitmap(FIF_GIF, mPath.c_str(), false, true, false, GIF_PLAYBACK);
+
+            mFrame = FreeImage_LockPage(mAnimation, mFrameNum);
+            mPictureRGBA.clear();
+            mPictureRGBA.resize(mFrameSize);
+
+            FreeImage_ConvertToRawBits(reinterpret_cast<BYTE*>(&mPictureRGBA.at(0)), mFrame,
+                                       FreeImage_GetPitch(mFrame), 32, FI_RGBA_RED, FI_RGBA_GREEN,
+                                       FI_RGBA_BLUE, 1);
+
+            mTexture->initFromPixels(&mPictureRGBA.at(0), mFileWidth, mFileHeight);
+
+            FreeImage_UnlockPage(mAnimation, mFrame, false);
+            FreeImage_CloseMultiBitmap(mAnimation, 0);
+
+            if (mDirection == "reverse")
+                --mFrameNum;
+            else
+                ++mFrameNum;
+        }
+    }
+
+    if (mTexture->getSize().x != 0.0f) {
+        mTexture->bind();
+
+        Renderer::Vertex vertices[4];
+
+        // clang-format off
+        vertices[0] = {{0.0f,    0.0f   }, {0.0f, 0.0f}, 0xFFFFFFFF};
+        vertices[1] = {{0.0f,    mSize.y}, {0.0f, 1.0f}, 0xFFFFFFFF};
+        vertices[2] = {{mSize.x, 0.0f   }, {1.0f, 0.0f}, 0xFFFFFFFF};
+        vertices[3] = {{mSize.x, mSize.y}, {1.0f, 1.0f}, 0xFFFFFFFF};
+        // clang-format on
+
+        // Round vertices.
+        for (int i = 0; i < 4; ++i)
+            vertices[i].pos = glm::round(vertices[i].pos);
+
+#if defined(USE_OPENGL_21)
+        // Perform color space conversion from BGRA to RGBA.
+        vertices[0].opacity = mThemeOpacity;
+        vertices[0].shaders = Renderer::SHADER_BGRA_TO_RGBA;
+#endif
+
+        // Render it.
+        Renderer::setMatrix(trans);
+        Renderer::drawTriangleStrips(&vertices[0], 4, trans);
+    }
 }
diff --git a/es-core/src/components/GIFAnimComponent.h b/es-core/src/components/GIFAnimComponent.h
index e97356c24..b17db8586 100644
--- a/es-core/src/components/GIFAnimComponent.h
+++ b/es-core/src/components/GIFAnimComponent.h
@@ -10,14 +10,64 @@
 #define ES_CORE_COMPONENTS_GIF_ANIM_COMPONENT_H
 
 #include "GuiComponent.h"
+#include "renderers/Renderer.h"
+#include "resources/TextureResource.h"
+#include "utils/MathUtil.h"
+
+#include <FreeImage.h>
+#include <chrono>
 
 class GIFAnimComponent : public GuiComponent
 {
 public:
     GIFAnimComponent();
-    ~GIFAnimComponent();
+
+    void setAnimation(const std::string& path);
+    void setKeepAspectRatio(bool value) { mKeepAspectRatio = value; }
+    void setPauseAnimation(bool state) { mExternalPause = state; }
+
+    void resetFileAnimation() override;
+    void onSizeChanged() override;
+
+    virtual void applyTheme(const std::shared_ptr<ThemeData>& theme,
+                            const std::string& view,
+                            const std::string& element,
+                            unsigned int properties) override;
+
+    void update(int deltaTime) override;
 
 private:
+    void render(const glm::mat4& parentTrans) override;
+
+    std::shared_ptr<TextureResource> mTexture;
+    std::vector<uint8_t> mPictureRGBA;
+    size_t mFrameSize;
+
+    std::chrono::time_point<std::chrono::system_clock> mAnimationStartTime;
+    FIMULTIBITMAP* mAnimation;
+    FIBITMAP* mFrame;
+    std::string mPath;
+    std::string mStartDirection;
+    std::string mDirection;
+    int mTotalFrames;
+    int mFrameNum;
+    int mFrameTime;
+
+    unsigned int mFileWidth;
+    unsigned int mFileHeight;
+
+    double mFrameRate;
+    float mSpeedModifier;
+    int mTargetPacing;
+    int mTimeAccumulator;
+    int mLastRenderedFrame;
+    int mSkippedFrames;
+
+    bool mHoldFrame;
+    bool mPause;
+    bool mExternalPause;
+    bool mAlternate;
+    bool mKeepAspectRatio;
 };
 
 #endif // ES_CORE_COMPONENTS_GIF_ANIM_COMPONENT_H
diff --git a/es-core/src/resources/TextureResource.h b/es-core/src/resources/TextureResource.h
index 8ac505a70..1ea4d1f75 100644
--- a/es-core/src/resources/TextureResource.h
+++ b/es-core/src/resources/TextureResource.h
@@ -46,6 +46,7 @@ public:
     }
 
     void setFormat(Renderer::Texture::Type format) { mTextureData->setFormat(format); }
+    void setLinearMagnify(bool setting) { mTextureData->setLinearMagnify(setting); }
 
     std::string getTextureFilePath();