diff --git a/Avalonia.Labs.Gif/Avalonia.Labs.Gif.csproj b/Avalonia.Labs.Gif/Avalonia.Labs.Gif.csproj
new file mode 100644
index 0000000000000000000000000000000000000000..c466e9018bae9e55843d359686a03d71af092e8d
--- /dev/null
+++ b/Avalonia.Labs.Gif/Avalonia.Labs.Gif.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFrameworks>netstandard2.1;net6.0</TargetFrameworks>
+    <LangVersion>latest</LangVersion>
+    <IsPackable>true</IsPackable>
+    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Avalonia" Version="11.1.3" />
+    <PackageReference Include="Avalonia.Skia" Version="11.1.3" /> 
+  </ItemGroup>
+
+</Project>
diff --git a/Avalonia.Labs.Gif/Decoding/BlockTypes.cs b/Avalonia.Labs.Gif/Decoding/BlockTypes.cs
new file mode 100644
index 0000000000000000000000000000000000000000..abfd3167393f4652d615d3933aa94156eff74afb
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/BlockTypes.cs
@@ -0,0 +1,9 @@
+namespace Avalonia.Labs.Gif.Decoding;
+
+internal enum BlockTypes
+{
+    Empty = 0,
+    Extension = 0x21,
+    ImageDescriptor = 0x2C,
+    Trailer = 0x3B,
+}
\ No newline at end of file
diff --git a/Avalonia.Labs.Gif/Decoding/ExtensionType.cs b/Avalonia.Labs.Gif/Decoding/ExtensionType.cs
new file mode 100644
index 0000000000000000000000000000000000000000..aa35e14af5c6b0b00f74d7dbcb644edddd19ce68
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/ExtensionType.cs
@@ -0,0 +1,7 @@
+namespace Avalonia.Labs.Gif.Decoding;
+
+internal enum ExtensionType
+{
+    GraphicsControl = 0xF9,
+    Application = 0xFF
+}
\ No newline at end of file
diff --git a/Avalonia.Labs.Gif/Decoding/FrameDisposal.cs b/Avalonia.Labs.Gif/Decoding/FrameDisposal.cs
new file mode 100644
index 0000000000000000000000000000000000000000..4f0607f12147dc02d28df2c6a652a830e7633818
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/FrameDisposal.cs
@@ -0,0 +1,9 @@
+namespace Avalonia.Labs.Gif.Decoding;
+
+internal enum FrameDisposal
+{
+    Unknown = 0,
+    Leave = 1,
+    Background = 2,
+    Restore = 3
+}
diff --git a/Avalonia.Labs.Gif/Decoding/GifColor.cs b/Avalonia.Labs.Gif/Decoding/GifColor.cs
new file mode 100644
index 0000000000000000000000000000000000000000..407673405296f927b88b5500a2890399b51fb6e5
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/GifColor.cs
@@ -0,0 +1,35 @@
+using System.Runtime.InteropServices;
+
+namespace Avalonia.Labs.Gif.Decoding;
+
+[StructLayout(LayoutKind.Explicit)]
+internal readonly struct GifColor
+{
+    [FieldOffset(3)]
+    public readonly byte A;
+
+    [FieldOffset(2)]
+    public readonly byte R;
+
+    [FieldOffset(1)]
+    public readonly byte G;
+        
+    [FieldOffset(0)]
+    public readonly byte B;
+
+    /// <summary>
+    /// A struct that represents a ARGB color and is aligned as
+    /// a BGRA bytefield in memory.
+    /// </summary>
+    /// <param name="r">Red</param>
+    /// <param name="g">Green</param>
+    /// <param name="b">Blue</param>
+    /// <param name="a">Alpha</param>
+    public GifColor(byte r, byte g, byte b, byte a = byte.MaxValue)
+    {
+            A = a;
+            R = r;
+            G = g;
+            B = b;
+    }
+}
diff --git a/Avalonia.Labs.Gif/Decoding/GifDecoder.cs b/Avalonia.Labs.Gif/Decoding/GifDecoder.cs
new file mode 100644
index 0000000000000000000000000000000000000000..63878bf5b7ea1e2ecc75dfb94dd41568dfe73422
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/GifDecoder.cs
@@ -0,0 +1,661 @@
+// This source file's Lempel-Ziv-Welch algorithm is derived from Chromium's Android GifPlayer
+// as seen here (https://github.com/chromium/chromium/blob/master/third_party/gif_player/src/jp/tomorrowkey/android/gifplayer)
+// Licensed under the Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
+// Copyright (C) 2015 The Gifplayer Authors. All Rights Reserved.
+
+// The rest of the source file is licensed under MIT License.
+// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
+
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using Avalonia.Media.Imaging;
+using static Avalonia.Labs.Gif.Extensions.StreamExtensions;
+
+namespace Avalonia.Labs.Gif.Decoding;
+
+internal sealed class GifDecoder : IDisposable
+{
+    private static readonly ReadOnlyMemory<byte> s_g87AMagic
+        = "GIF87a"u8.ToArray().AsMemory();
+
+    private static readonly ReadOnlyMemory<byte> s_g89AMagic
+        = "GIF89a"u8.ToArray().AsMemory();
+
+    private static readonly ReadOnlyMemory<byte> s_netscapeMagic
+        = "NETSCAPE2.0"u8.ToArray().AsMemory();
+
+    private static readonly TimeSpan s_frameDelayThreshold = TimeSpan.FromMilliseconds(10);
+    private static readonly TimeSpan s_frameDelayDefault = TimeSpan.FromMilliseconds(100);
+    private static readonly GifColor s_transparentColor = new(0, 0, 0, 0);
+    private const int MaxTempBuf = 768;
+    private const int MaxStackSize = 4096;
+    private const int MaxBits = 4097;
+
+    private readonly Stream _fileStream;
+    private readonly CancellationToken _currentCtsToken;
+    private readonly bool _hasFrameBackups;
+
+    private int _gctSize, _prevFrame = -1, _backupFrame = -1;
+    private bool _gctUsed;
+
+    private GifRect _gifDimensions;
+
+    private readonly int _backBufferBytes;
+    private GifColor[]? _bitmapBackBuffer;
+
+    private short[]? _prefixBuf;
+    private byte[]? _suffixBuf;
+    private byte[]? _pixelStack;
+    private byte[]? _indexBuf;
+    private byte[]? _backupFrameIndexBuf;
+    private volatile bool _hasNewFrame;
+
+    public GifHeader? Header { get; private set; }
+
+    internal readonly List<GifFrame> _frames = new();
+
+    public PixelSize Size => new(Header?.Dimensions.Width ?? 0, Header?.Dimensions.Height ?? 0);
+
+    public GifDecoder(Stream fileStream, CancellationToken currentCtsToken)
+    {
+        _fileStream = fileStream;
+        _currentCtsToken = currentCtsToken;
+
+        ProcessHeaderData();
+        ProcessFrameData();
+
+        if (Header != null)
+            Header.IterationCount = Header.Iterations switch
+            {
+                -1 => new GifRepeatBehavior { Count = 1 },
+                0 => new GifRepeatBehavior { LoopForever = true },
+                > 0 => new GifRepeatBehavior { Count = Header.Iterations },
+                _ => Header.IterationCount
+            };
+
+        var pixelCount = _gifDimensions.TotalPixels;
+
+        _hasFrameBackups = _frames
+            .Any(f => f.FrameDisposalMethod == FrameDisposal.Restore);
+
+        _bitmapBackBuffer = new GifColor[pixelCount];
+        _indexBuf = new byte[pixelCount];
+
+        if (_hasFrameBackups)
+            _backupFrameIndexBuf = new byte[pixelCount];
+
+        _prefixBuf = new short[MaxStackSize];
+        _suffixBuf = new byte[MaxStackSize];
+        _pixelStack = new byte[MaxStackSize + 1];
+
+        _backBufferBytes = pixelCount * Marshal.SizeOf(typeof(GifColor));
+    }
+
+    public void Dispose()
+    {
+        _frames.Clear();
+
+        _bitmapBackBuffer = null;
+        _prefixBuf = null;
+        _suffixBuf = null;
+        _pixelStack = null;
+        _indexBuf = null;
+        _backupFrameIndexBuf = null;
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private int PixelCoordinate(int x, int y) => x + y * _gifDimensions.Width;
+
+    private static readonly (int Start, int Step)[] s_pass = [(0, 8), (4, 8), (2, 4), (1, 2)];
+
+    private void ClearImage()
+    {
+        if (_bitmapBackBuffer != null)
+            Array.Fill(_bitmapBackBuffer, s_transparentColor);
+
+        _prevFrame = -1;
+        _backupFrame = -1;
+    }
+
+    public void RenderFrame(int fIndex, WriteableBitmap? writeableBitmap, bool forceClear = false)
+    {
+        if (_currentCtsToken.IsCancellationRequested)
+            return;
+
+        if (fIndex < 0 | fIndex >= _frames.Count)
+            return;
+
+        if (_prevFrame == fIndex)
+            return;
+
+        if (fIndex == 0 || forceClear || fIndex < _prevFrame)
+            ClearImage();
+
+        DisposePreviousFrame();
+
+        _prevFrame++;
+
+        // render intermediate frame
+        for (int idx = _prevFrame; idx < fIndex; ++idx)
+        {
+            var prevFrame = _frames[idx];
+
+            if (prevFrame.FrameDisposalMethod == FrameDisposal.Restore)
+                continue;
+
+            if (prevFrame.FrameDisposalMethod == FrameDisposal.Background)
+            {
+                ClearArea(prevFrame.Dimensions);
+                continue;
+            }
+
+            RenderFrameAt(idx, writeableBitmap);
+        }
+
+        RenderFrameAt(fIndex, writeableBitmap);
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void RenderFrameAt(int idx, WriteableBitmap? writeableBitmap)
+    {
+        if(writeableBitmap is null)
+            return;
+
+        var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf);
+
+        var curFrame = _frames[idx];
+        DecompressFrameToIndexBuffer(curFrame, _indexBuf, tmpB);
+
+        if (_hasFrameBackups & curFrame.ShouldBackup
+            && _indexBuf != null && _backupFrameIndexBuf != null)
+        {
+            Buffer.BlockCopy(_indexBuf, 0,
+                _backupFrameIndexBuf, 0,
+                curFrame.Dimensions.TotalPixels);
+            _backupFrame = idx;
+        }
+
+        DrawFrame(curFrame, _indexBuf);
+
+        _prevFrame = idx;
+        _hasNewFrame = true;
+
+        using var lockedBitmap = writeableBitmap.Lock();
+        WriteBackBufToFb(lockedBitmap.Address);
+
+        ArrayPool<byte>.Shared.Return(tmpB);
+    }
+
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void DrawFrame(GifFrame curFrame, Memory<byte> frameIndexSpan)
+    {
+        var activeColorTable =
+            curFrame.IsLocalColorTableUsed ? curFrame.LocalColorTable : Header?.GlobalColorTable;
+
+        var cX = curFrame.Dimensions.X;
+        var cY = curFrame.Dimensions.Y;
+        var cH = curFrame.Dimensions.Height;
+        var cW = curFrame.Dimensions.Width;
+        var tC = curFrame.TransparentColorIndex;
+        var hT = curFrame.HasTransparency;
+
+        if (curFrame.IsInterlaced)
+        {
+            int curSrcRow = 0;
+            for (var i = 0; i < 4; i++)
+            {
+                var curPass = s_pass[i];
+                var y = curPass.Start;
+                while (y < cH)
+                {
+                    DrawRow(curSrcRow++, y);
+                    y += curPass.Step;
+                }
+            }
+        }
+        else
+        {
+            for (var i = 0; i < cH; i++)
+                DrawRow(i, i);
+        }
+
+        return;
+
+        void DrawRow(int srcRow, int destRow)
+        {
+            // Get the starting point of the current row on frame's index stream.
+            var indexOffset = srcRow * cW;
+
+            // Get the target back buffer offset from the frames coords.
+            var targetOffset = PixelCoordinate(cX, destRow + cY);
+            if (_bitmapBackBuffer == null) return;
+            var len = _bitmapBackBuffer.Length;
+
+            for (var i = 0; i < cW; i++)
+            {
+                var indexColor = frameIndexSpan.Span[indexOffset + i];
+
+                if (activeColorTable == null || targetOffset >= len ||
+                    indexColor > activeColorTable.Length) return;
+
+                if (!(hT & indexColor == tC))
+                    _bitmapBackBuffer[targetOffset] = activeColorTable[indexColor];
+
+                targetOffset++;
+            }
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void DisposePreviousFrame()
+    {
+        if (_prevFrame == -1)
+            return;
+
+        var prevFrame = _frames[_prevFrame];
+
+        switch (prevFrame.FrameDisposalMethod)
+        {
+            case FrameDisposal.Background:
+                ClearArea(prevFrame.Dimensions);
+                break;
+            case FrameDisposal.Restore:
+                if (_hasFrameBackups && _backupFrame != -1)
+                    DrawFrame(_frames[_backupFrame], _backupFrameIndexBuf);
+                else
+                    ClearArea(prevFrame.Dimensions);
+                break;
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void ClearArea(GifRect area)
+    {
+        if (_bitmapBackBuffer is null) return;
+
+        for (var y = 0; y < area.Height; y++)
+        {
+            var targetOffset = PixelCoordinate(area.X, y + area.Y);
+            for (var x = 0; x < area.Width; x++)
+                _bitmapBackBuffer[targetOffset + x] = s_transparentColor;
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private void DecompressFrameToIndexBuffer(GifFrame curFrame, Span<byte> indexSpan, byte[] tempBuf)
+    {
+        if (_prefixBuf is null || _suffixBuf is null || _pixelStack is null) return;
+
+        _fileStream.Position = curFrame.LzwStreamPosition;
+        var totalPixels = curFrame.Dimensions.TotalPixels;
+
+        // Initialize GIF data stream decoder.
+        var dataSize = curFrame.LzwMinCodeSize;
+        var clear = 1 << dataSize;
+        var endOfInformation = clear + 1;
+        var available = clear + 2;
+        var oldCode = -1;
+        var codeSize = dataSize + 1;
+        var codeMask = (1 << codeSize) - 1;
+
+        for (var code = 0; code < clear; code++)
+        {
+            _prefixBuf[code] = 0;
+            _suffixBuf[code] = (byte)code;
+        }
+
+        // Decode GIF pixel stream.
+        int bits, first, top, pixelIndex;
+        var datum = bits = first = top = pixelIndex = 0;
+
+        while (pixelIndex < totalPixels)
+        {
+            var blockSize = _fileStream.ReadBlock(tempBuf);
+
+            if (blockSize == 0)
+                break;
+
+            var blockPos = 0;
+
+            while (blockPos < blockSize)
+            {
+                datum += tempBuf[blockPos] << bits;
+                blockPos++;
+
+                bits += 8;
+
+                while (bits >= codeSize)
+                {
+                    // Get the next code.
+                    var code = datum & codeMask;
+                    datum >>= codeSize;
+                    bits -= codeSize;
+
+                    // Interpret the code
+                    if (code == clear)
+                    {
+                        // Reset decoder.
+                        codeSize = dataSize + 1;
+                        codeMask = (1 << codeSize) - 1;
+                        available = clear + 2;
+                        oldCode = -1;
+                        continue;
+                    }
+
+                    // Check for explicit end-of-stream
+                    if (code == endOfInformation)
+                        return;
+
+                    if (oldCode == -1)
+                    {
+                        indexSpan[pixelIndex++] = _suffixBuf[code];
+                        oldCode = code;
+                        first = code;
+                        continue;
+                    }
+
+                    var inCode = code;
+                    if (code >= available)
+                    {
+                        _pixelStack[top++] = (byte)first;
+                        code = oldCode;
+
+                        if (top == MaxBits)
+                            ThrowLswException();
+                    }
+
+                    while (code >= clear)
+                    {
+                        if (code >= MaxBits || code == _prefixBuf[code])
+                            ThrowLswException();
+
+                        _pixelStack[top++] = _suffixBuf[code];
+                        code = _prefixBuf[code];
+
+                        if (top == MaxBits)
+                            ThrowLswException();
+                    }
+
+                    first = _suffixBuf[code];
+                    _pixelStack[top++] = (byte)first;
+
+                    // Add new code to the dictionary
+                    if (available < MaxStackSize)
+                    {
+                        _prefixBuf[available] = (short)oldCode;
+                        _suffixBuf[available] = (byte)first;
+                        available++;
+
+                        if ((available & codeMask) == 0 && available < MaxStackSize)
+                        {
+                            codeSize++;
+                            codeMask += available;
+                        }
+                    }
+
+                    oldCode = inCode;
+
+                    // Drain the pixel stack.
+                    do
+                    {
+                        indexSpan[pixelIndex++] = _pixelStack[--top];
+                    } while (top > 0);
+                }
+            }
+        }
+
+        while (pixelIndex < totalPixels)
+            indexSpan[pixelIndex++] = 0; // clear missing pixels
+    }
+
+    private static void ThrowLswException() => throw new LzwDecompressionException();
+
+    /// <summary>
+    /// Directly copies the <see cref="GifColor"/> struct array to a bitmap IntPtr.
+    /// </summary>
+    private void WriteBackBufToFb(IntPtr targetPointer)
+    {
+        if (_currentCtsToken.IsCancellationRequested)
+            return;
+
+        if (!(_hasNewFrame && _bitmapBackBuffer != null)) return;
+
+        unsafe
+        {
+            fixed (void* src = &_bitmapBackBuffer[0])
+                Buffer.MemoryCopy(src, targetPointer.ToPointer(), (uint)_backBufferBytes,
+                    (uint)_backBufferBytes);
+            _hasNewFrame = false;
+        }
+    }
+
+    /// <summary>
+    /// Processes GIF Header.
+    /// </summary>
+    private void ProcessHeaderData()
+    {
+        var str = _fileStream;
+        var tmpB = ArrayPool<byte>.Shared.Rent(MaxTempBuf);
+        var tempBuf = tmpB.AsSpan();
+
+        var _ = str.Read(tmpB, 0, 6);
+
+        if (!tempBuf[..3].SequenceEqual(s_g87AMagic[..3].Span))
+            throw new InvalidGifStreamException("Not a GIF stream.");
+
+        if (!(tempBuf[..6].SequenceEqual(s_g87AMagic.Span) |
+              tempBuf[..6].SequenceEqual(s_g89AMagic.Span)))
+            throw new InvalidGifStreamException("Unsupported GIF Version: " +
+                                                Encoding.ASCII.GetString(tempBuf[..6].ToArray()));
+
+        ProcessScreenDescriptor(tmpB);
+
+        Header = new GifHeader
+        {
+            Dimensions = _gifDimensions,
+            GlobalColorTable =
+                _gctUsed ? ProcessColorTable(ref str, tmpB, _gctSize) : [],
+            HeaderSize = _fileStream.Position
+        };
+
+        ArrayPool<byte>.Shared.Return(tmpB);
+    }
+
+    /// <summary>
+    /// Parses colors from file stream to target color table.
+    /// </summary> 
+    private static GifColor[] ProcessColorTable(ref Stream stream, byte[] rawBufSpan, int nColors)
+    {
+        var nBytes = 3 * nColors;
+        var target = new GifColor[nColors];
+
+        var n = stream.Read(rawBufSpan, 0, nBytes);
+
+        if (n < nBytes)
+            throw new InvalidOperationException("Wrong color table bytes.");
+
+        int i = 0, j = 0;
+
+        while (i < nColors)
+        {
+            var r = rawBufSpan[j++];
+            var g = rawBufSpan[j++];
+            var b = rawBufSpan[j++];
+            target[i++] = new GifColor(r, g, b);
+        }
+
+        return target;
+    }
+
+    /// <summary>
+    /// Parses screen and other GIF descriptors. 
+    /// </summary>
+    private void ProcessScreenDescriptor(byte[] tempBuf)
+    {
+        var width = _fileStream.ReadUShortS(tempBuf);
+        var height = _fileStream.ReadUShortS(tempBuf);
+
+        var packed = _fileStream.ReadByteS(tempBuf);
+
+        _gctUsed = (packed & 0x80) != 0;
+        _gctSize = 2 << (packed & 7);
+        _ = _fileStream.ReadByteS(tempBuf);
+
+        _gifDimensions = new GifRect(0, 0, width, height);
+        _fileStream.Skip(1);
+    }
+
+    /// <summary>
+    /// Parses all frame data.
+    /// </summary>
+    private void ProcessFrameData()
+    {
+        _fileStream.Position = Header?.HeaderSize ?? -1;
+
+        var tempBuf = ArrayPool<byte>.Shared.Rent(MaxTempBuf);
+
+        var terminate = false;
+        var curFrame = 0;
+
+        _frames.Add(new GifFrame());
+
+        do
+        {
+            var blockType = (BlockTypes)_fileStream.ReadByteS(tempBuf);
+
+            switch (blockType)
+            {
+                case BlockTypes.Empty:
+                    break;
+
+                case BlockTypes.Extension:
+                    ProcessExtensions(ref curFrame, tempBuf);
+                    break;
+
+                case BlockTypes.ImageDescriptor:
+                    ProcessImageDescriptor(ref curFrame, tempBuf);
+                    _fileStream.SkipBlocks(tempBuf);
+                    break;
+
+                case BlockTypes.Trailer:
+                    _frames.RemoveAt(_frames.Count - 1);
+                    terminate = true;
+                    break;
+
+                default:
+                    _fileStream.SkipBlocks(tempBuf);
+                    break;
+            }
+
+            // Break the loop when the stream is not valid anymore.
+            if (_fileStream.Position >= _fileStream.Length & terminate == false)
+                throw new InvalidProgramException("Reach the end of the filestream without trailer block.");
+        } while (!terminate);
+
+        ArrayPool<byte>.Shared.Return(tempBuf);
+    }
+
+    /// <summary>
+    /// Parses GIF Image Descriptor Block.
+    /// </summary>
+    private void ProcessImageDescriptor(ref int curFrame, byte[] tempBuf)
+    {
+        var str = _fileStream;
+        var currentFrame = _frames[curFrame];
+
+        // Parse frame dimensions.
+        var frameX = str.ReadUShortS(tempBuf);
+        var frameY = str.ReadUShortS(tempBuf);
+        var frameW = str.ReadUShortS(tempBuf);
+        var frameH = str.ReadUShortS(tempBuf);
+
+        frameW = (ushort)Math.Min(frameW, _gifDimensions.Width - frameX);
+        frameH = (ushort)Math.Min(frameH, _gifDimensions.Height - frameY);
+
+        currentFrame.Dimensions = new GifRect(frameX, frameY, frameW, frameH);
+
+        // Unpack interlace and lct info.
+        var packed = str.ReadByteS(tempBuf);
+        currentFrame.IsInterlaced = (packed & 0x40) != 0;
+        currentFrame.IsLocalColorTableUsed = (packed & 0x80) != 0;
+        currentFrame.LocalColorTableSize = (int)Math.Pow(2, (packed & 0x07) + 1);
+
+        if (currentFrame.IsLocalColorTableUsed)
+            currentFrame.LocalColorTable =
+                ProcessColorTable(ref str, tempBuf, currentFrame.LocalColorTableSize);
+
+        currentFrame.LzwMinCodeSize = str.ReadByteS(tempBuf);
+        currentFrame.LzwStreamPosition = str.Position;
+
+        curFrame += 1;
+        _frames.Add(new GifFrame());
+    }
+
+    /// <summary>
+    /// Parses GIF Extension Blocks.
+    /// </summary>
+    private void ProcessExtensions(ref int curFrame, byte[] tempBuf)
+    {
+        var extType = (ExtensionType)_fileStream.ReadByteS(tempBuf);
+
+        switch (extType)
+        {
+            case ExtensionType.GraphicsControl:
+
+                _fileStream.ReadBlock(tempBuf);
+                var currentFrame = _frames[curFrame];
+                var packed = tempBuf[0];
+
+                currentFrame.FrameDisposalMethod = (FrameDisposal)((packed & 0x1c) >> 2);
+
+                if (currentFrame.FrameDisposalMethod != FrameDisposal.Restore
+                    && currentFrame.FrameDisposalMethod != FrameDisposal.Background)
+                    currentFrame.ShouldBackup = true;
+
+                currentFrame.HasTransparency = (packed & 1) != 0;
+
+                currentFrame.FrameDelay =
+                    TimeSpan.FromMilliseconds(SpanToShort(tempBuf.AsSpan(1)) * 10);
+
+                if (currentFrame.FrameDelay <= s_frameDelayThreshold)
+                    currentFrame.FrameDelay = s_frameDelayDefault;
+
+                currentFrame.TransparentColorIndex = tempBuf[3];
+                break;
+
+            case ExtensionType.Application:
+                var blockLen = _fileStream.ReadBlock(tempBuf);
+                var _ = tempBuf.AsSpan(0, blockLen);
+                var blockHeader = tempBuf.AsSpan(0, s_netscapeMagic.Length);
+
+                if (blockHeader.SequenceEqual(s_netscapeMagic.Span))
+                {
+                    var count = 1;
+
+                    while (count > 0)
+                        count = _fileStream.ReadBlock(tempBuf);
+
+                    var iterationCount = SpanToShort(tempBuf.AsSpan(1));
+
+                    if (Header != null)
+                        Header.Iterations = iterationCount;
+                }
+                else
+                    _fileStream.SkipBlocks(tempBuf);
+
+                break;
+
+            default:
+                _fileStream.SkipBlocks(tempBuf);
+                break;
+        }
+    }
+}
diff --git a/Avalonia.Labs.Gif/Decoding/GifFrame.cs b/Avalonia.Labs.Gif/Decoding/GifFrame.cs
new file mode 100644
index 0000000000000000000000000000000000000000..e10cbb6345f8ce1fa8eda5a9f23f338630913a89
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/GifFrame.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Avalonia.Labs.Gif.Decoding;
+
+internal class GifFrame
+{
+    public bool HasTransparency, IsInterlaced, IsLocalColorTableUsed;
+    public byte TransparentColorIndex;
+    public int LzwMinCodeSize, LocalColorTableSize;
+    public long LzwStreamPosition;
+    public TimeSpan FrameDelay;
+    public FrameDisposal FrameDisposalMethod;
+    public bool ShouldBackup;
+    public GifRect Dimensions;
+    public GifColor[]? LocalColorTable;
+}
diff --git a/Avalonia.Labs.Gif/Decoding/GifHeader.cs b/Avalonia.Labs.Gif/Decoding/GifHeader.cs
new file mode 100644
index 0000000000000000000000000000000000000000..69ae294436da5cbd3f8d94b2dbbedde582f0880b
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/GifHeader.cs
@@ -0,0 +1,13 @@
+// Licensed under the MIT License.
+// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
+
+namespace Avalonia.Labs.Gif.Decoding;
+
+internal class GifHeader
+{
+    public long HeaderSize;
+    internal int Iterations = -1;
+    public GifRepeatBehavior? IterationCount;
+    public GifRect Dimensions;
+    public GifColor[]? GlobalColorTable;
+}
diff --git a/Avalonia.Labs.Gif/Decoding/GifRect.cs b/Avalonia.Labs.Gif/Decoding/GifRect.cs
new file mode 100644
index 0000000000000000000000000000000000000000..d4d6f817d66ffeed6acd921bd2d3f9f92d8d5448
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/GifRect.cs
@@ -0,0 +1,45 @@
+namespace Avalonia.Labs.Gif.Decoding;
+
+internal readonly struct GifRect
+{
+    public int X { get; }
+    public int Y { get; }
+    public int Width { get; }
+    public int Height { get; }
+    public int TotalPixels { get; }
+
+    public GifRect(int x, int y, int width, int height)
+    {
+        X = x;
+        Y = y;
+        Width = width;
+        Height = height;
+        TotalPixels = width * height;
+    }
+
+    public static bool operator ==(GifRect a, GifRect b)
+    {
+        return a.X == b.X &&
+               a.Y == b.Y &&
+               a.Width == b.Width &&
+               a.Height == b.Height;
+    }
+
+    public static bool operator !=(GifRect a, GifRect b)
+    {
+        return !(a == b);
+    }
+
+    public override bool Equals(object? obj)
+    {
+        if (obj == null || GetType() != obj.GetType())
+            return false;
+
+        return this == (GifRect)obj;
+    }
+
+    public override int GetHashCode()
+    {
+        return X.GetHashCode() ^ Y.GetHashCode() | Width.GetHashCode() ^ Height.GetHashCode();
+    }
+}
diff --git a/Avalonia.Labs.Gif/Decoding/GifRepeatBehavior.cs b/Avalonia.Labs.Gif/Decoding/GifRepeatBehavior.cs
new file mode 100644
index 0000000000000000000000000000000000000000..a60799532b3c87ce3ffbbaecc37e570848075f38
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/GifRepeatBehavior.cs
@@ -0,0 +1,7 @@
+namespace Avalonia.Labs.Gif.Decoding;
+
+internal class GifRepeatBehavior
+{
+    public bool LoopForever { get; set; }
+    public int? Count { get; set; }
+}
diff --git a/Avalonia.Labs.Gif/Decoding/InvalidGifStreamException.cs b/Avalonia.Labs.Gif/Decoding/InvalidGifStreamException.cs
new file mode 100644
index 0000000000000000000000000000000000000000..bc434ab971361f68b332ee575936fdc66f039b03
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/InvalidGifStreamException.cs
@@ -0,0 +1,9 @@
+// Licensed under the MIT License.
+// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
+
+using System;
+
+namespace Avalonia.Labs.Gif.Decoding;
+
+[Serializable]
+internal sealed class InvalidGifStreamException(string message) : Exception(message);
diff --git a/Avalonia.Labs.Gif/Decoding/LzwDecompressionException.cs b/Avalonia.Labs.Gif/Decoding/LzwDecompressionException.cs
new file mode 100644
index 0000000000000000000000000000000000000000..e380e732fdd6fa34d07e74cf87d23c2ddf2f28f5
--- /dev/null
+++ b/Avalonia.Labs.Gif/Decoding/LzwDecompressionException.cs
@@ -0,0 +1,9 @@
+// Licensed under the MIT License.
+// Copyright (C) 2018 Jumar A. Macato, All Rights Reserved.
+
+using System;
+
+namespace Avalonia.Labs.Gif.Decoding;
+
+[Serializable]
+internal class LzwDecompressionException : Exception { }
diff --git a/Avalonia.Labs.Gif/Extensions/StreamExtensions.cs b/Avalonia.Labs.Gif/Extensions/StreamExtensions.cs
new file mode 100644
index 0000000000000000000000000000000000000000..1cbae59383bafa52eb5dd8d87796eca61ddf4d10
--- /dev/null
+++ b/Avalonia.Labs.Gif/Extensions/StreamExtensions.cs
@@ -0,0 +1,79 @@
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using Avalonia.Labs.Gif.Decoding;
+
+namespace Avalonia.Labs.Gif.Extensions;
+
+internal static class StreamExtensions
+{
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static ushort SpanToShort(Span<byte> b) => (ushort)(b[0] | (b[1] << 8));
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static void Skip(this Stream stream, long count)
+    {
+        stream.Position += count;
+    }
+
+    /// <summary>
+    /// Read a Gif block from stream while advancing the position.
+    /// </summary>
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static int ReadBlock(this Stream stream, byte[] tempBuf)
+    {
+        _ = stream.Read(tempBuf, 0, 1);
+
+        var blockLength = (int)tempBuf[0];
+
+        if (blockLength > 0)
+            _ = stream.Read(tempBuf, 0, blockLength);
+
+        // Guard against infinite loop.
+        if (stream.Position >= stream.Length)
+            throw new InvalidGifStreamException("Reach the end of the filestream without trailer block.");
+
+        return blockLength;
+    }
+
+    /// <summary>
+    /// Skips GIF blocks until it encounters an empty block.
+    /// </summary>
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static void SkipBlocks(this Stream stream, byte[] tempBuf)
+    {
+        int blockLength;
+        do
+        {
+            _ = stream.Read(tempBuf, 0, 1);
+
+            blockLength = tempBuf[0];
+            stream.Position += blockLength;
+
+            // Guard against infinite loop.
+            if (stream.Position >= stream.Length)
+                throw new InvalidGifStreamException("Reach the end of the filestream without trailer block.");
+        } while (blockLength > 0);
+    }
+
+    /// <summary>
+    /// Read a <see cref="ushort"/> from stream by providing a temporary buffer.
+    /// </summary>
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static ushort ReadUShortS(this Stream stream, byte[] tempBuf)
+    {
+        _ = stream.Read(tempBuf, 0, 2);
+        return SpanToShort(tempBuf);
+    }
+
+    /// <summary>
+    /// Read a <see cref="ushort"/> from stream by providing a temporary buffer.
+    /// </summary>
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static byte ReadByteS(this Stream stream, byte[] tempBuf)
+    {
+        _ = stream.Read(tempBuf, 0, 1);
+        var finalVal = tempBuf[0];
+        return finalVal;
+    }
+}
diff --git a/Avalonia.Labs.Gif/GifCompositionCustomVisualHandler.cs b/Avalonia.Labs.Gif/GifCompositionCustomVisualHandler.cs
new file mode 100644
index 0000000000000000000000000000000000000000..3a270213abdb83e0e4acdc534e8cfdf3281576d4
--- /dev/null
+++ b/Avalonia.Labs.Gif/GifCompositionCustomVisualHandler.cs
@@ -0,0 +1,128 @@
+using System;
+using Avalonia.Media;
+using Avalonia.Rendering.Composition;
+
+namespace Avalonia.Labs.Gif;
+
+internal class GifCompositionCustomVisualHandler : CompositionCustomVisualHandler
+{
+    private bool _running;
+    private Stretch? _stretch;
+    private StretchDirection? _stretchDirection;
+    private Size _GifSize;
+    private readonly object _sync = new();
+    private bool _isDisposed;
+    private GifInstance? _gifInstance;
+
+    private TimeSpan _animationElapsed;
+    private TimeSpan _lastServerTime;
+
+    public override void OnMessage(object message)
+    {
+        if (message is not GifDrawPayload msg)
+        {
+            return;
+        }
+
+        switch (msg)
+        {
+            case
+            {
+                HandlerCommand: HandlerCommand.Start, Source: { } uri, IterationCount: { } iteration,
+                Stretch: { } st, StretchDirection: { } sd
+            }:
+            {
+                _gifInstance = new GifInstance(uri);
+
+                _gifInstance.IterationCount = iteration;
+
+                _lastServerTime = CompositionNow;
+                _GifSize = _gifInstance.GifPixelSize.ToSize(1);
+                _running = true;
+                _stretch = st;
+                _stretchDirection = sd;
+                RegisterForNextAnimationFrameUpdate();
+                break;
+            }
+            case
+            {
+                HandlerCommand: HandlerCommand.Update, Stretch: { } st, IterationCount: { } iteration,
+                StretchDirection: { } sd
+            }:
+            {
+                _stretch = st;
+                _stretchDirection = sd;
+                if (_gifInstance != null)
+                    _gifInstance.IterationCount = iteration;
+                RegisterForNextAnimationFrameUpdate();
+                break;
+            }
+            case { HandlerCommand: HandlerCommand.Stop }:
+            {
+                _running = false;
+                break;
+            }
+            case { HandlerCommand: HandlerCommand.Dispose }:
+            {
+                DisposeImpl();
+                break;
+            }
+        }
+    }
+
+    public override void OnAnimationFrameUpdate()
+    {
+        if (!_running || _isDisposed)
+            return;
+
+        Invalidate();
+        RegisterForNextAnimationFrameUpdate();
+    }
+
+    public override void OnRender(ImmediateDrawingContext context)
+    {
+        lock (_sync)
+        {
+            if (_stretch is not { } st
+                || _stretchDirection is not { } sd
+                || _gifInstance is null
+                || _isDisposed
+                || !_running)
+            {
+                return;
+            }
+
+            _animationElapsed += CompositionNow - _lastServerTime;
+            _lastServerTime = CompositionNow;
+
+            var bounds = GetRenderBounds().Size;
+            var viewPort = new Rect(bounds);
+
+            var scale = st.CalculateScaling(bounds, _GifSize, sd);
+            var scaledSize = _GifSize * scale;
+            var destRect = viewPort
+                .CenterRect(new Rect(scaledSize))
+                .Intersect(viewPort);
+
+            var bitmap = _gifInstance.ProcessFrameTime(_animationElapsed);
+            if (bitmap is not null)
+            {
+                context.DrawBitmap(bitmap, new Rect(_gifInstance.GifPixelSize.ToSize(1)),
+                    destRect);
+            }
+        }
+    }
+
+    private void DisposeImpl()
+    {
+        lock (_sync)
+        {
+            if (_isDisposed) return;
+            _isDisposed = true;
+            _gifInstance?.Dispose();
+            _animationElapsed = TimeSpan.Zero;
+            _lastServerTime = TimeSpan.Zero;
+            _running = false;
+        }
+    }
+}
diff --git a/Avalonia.Labs.Gif/GifDrawPayload.cs b/Avalonia.Labs.Gif/GifDrawPayload.cs
new file mode 100644
index 0000000000000000000000000000000000000000..b80149f94f99bfc201c58f83fac0c88a35aa62c5
--- /dev/null
+++ b/Avalonia.Labs.Gif/GifDrawPayload.cs
@@ -0,0 +1,14 @@
+using System;
+using Avalonia.Animation;
+using Avalonia.Media;
+
+namespace Avalonia.Labs.Gif;
+
+internal record struct GifDrawPayload(
+    HandlerCommand HandlerCommand,
+    Uri? Source = default,
+    Size? GifSize = default,
+    Size? Size = default,
+    Stretch? Stretch = default,
+    StretchDirection? StretchDirection = default,
+    IterationCount? IterationCount = default);
\ No newline at end of file
diff --git a/Avalonia.Labs.Gif/GifImage.cs b/Avalonia.Labs.Gif/GifImage.cs
new file mode 100644
index 0000000000000000000000000000000000000000..f060406c79ca7ed490baaccb43eb1eaffbc7985e
--- /dev/null
+++ b/Avalonia.Labs.Gif/GifImage.cs
@@ -0,0 +1,237 @@
+using System;
+using System.IO;
+using System.Numerics;
+using System.Threading;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Labs.Gif.Decoding;
+using Avalonia.Media;
+using Avalonia.Platform;
+using Avalonia.Rendering.Composition;
+
+namespace Avalonia.Labs.Gif;
+
+/// <summary>
+/// A control that presents GIF animations.
+/// </summary>
+public class GifImage : Control
+{
+    private CompositionCustomVisual? _customVisual;
+
+    private double _gifWidth, _gifHeight;
+
+    /// <summary>
+    /// Defines the <see cref="Source"/> property.
+    /// </summary>
+    public static readonly StyledProperty<Uri> SourceProperty =
+        AvaloniaProperty.Register<GifImage, Uri>(nameof(Source));
+ 
+    /// <summary>
+    /// Defines the <see cref="IterationCount"/> property.
+    /// </summary>
+    public static readonly StyledProperty<IterationCount> IterationCountProperty =
+        AvaloniaProperty.Register<GifImage, IterationCount>(nameof(IterationCount), IterationCount.Infinite);
+
+    /// <summary>
+    /// Defines the <see cref="StretchDirection"/> property.
+    /// </summary>
+    public static readonly StyledProperty<StretchDirection> StretchDirectionProperty =
+        AvaloniaProperty.Register<GifImage, StretchDirection>(nameof(StretchDirection));
+
+    /// <summary>
+    /// Defines the <see cref="Stretch"/> property.
+    /// </summary>
+    public static readonly StyledProperty<Stretch> StretchProperty =
+        AvaloniaProperty.Register<GifImage, Stretch>(nameof(Stretch));
+
+    /// <summary>
+    /// Gets or sets the uri pointing to the GIF image resource
+    /// </summary>
+    public Uri Source
+    {
+        get => GetValue(SourceProperty);
+        set => SetValue(SourceProperty, value);
+    }
+
+    /// <summary>
+    /// Gets or sets a value controlling how the image will be stretched.
+    /// </summary>
+    public Stretch Stretch
+    {
+        get => GetValue(StretchProperty);
+        set => SetValue(StretchProperty, value);
+    }
+
+    /// <summary>
+    /// Gets or sets a value controlling in what direction the image will be stretched.
+    /// </summary>
+    public StretchDirection StretchDirection
+    {
+        get => GetValue(StretchDirectionProperty);
+        set => SetValue(StretchDirectionProperty, value);
+    }
+
+    /// <summary>
+    /// Gets or sets the amount in which the GIF image loops.
+    /// </summary>
+    public IterationCount IterationCount
+    {
+        get => GetValue(IterationCountProperty);
+        set => SetValue(IterationCountProperty, value);
+    }
+
+    static GifImage()
+    {
+        AffectsRender<GifImage>(SourceProperty,
+            StretchProperty,
+            StretchDirectionProperty,
+            WidthProperty,
+            HeightProperty);
+
+        AffectsMeasure<GifImage>(SourceProperty,
+            StretchProperty,
+            StretchDirectionProperty,
+            WidthProperty,
+            HeightProperty);
+    }
+
+    private Size GetGifSize()
+    {
+        return new Size(_gifWidth, _gifHeight);
+    }
+
+    /// <summary>
+    /// Measures the control.
+    /// </summary>
+    /// <param name="availableSize">The available size.</param>
+    /// <returns>The desired size of the control.</returns>
+    protected override Size MeasureOverride(Size availableSize)
+    {
+        return Stretch.CalculateSize(availableSize, GetGifSize(), StretchDirection);
+    }
+
+    /// <inheritdoc/>
+    protected override Size ArrangeOverride(Size finalSize)
+    {
+        var sourceSize = GetGifSize();
+        var result = Stretch.CalculateSize(finalSize, sourceSize);
+        return result;
+    }
+
+    /// <inheritdoc />
+    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+    {
+        base.OnAttachedToVisualTree(e);
+
+        InitializeGif();
+    }
+
+    /// <inheritdoc /> 
+    protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+    {
+        base.OnPropertyChanged(change);
+        var avProp = change.Property;
+
+        if (avProp == SourceProperty)
+        {
+            InitializeGif();
+        }
+
+        if ((avProp == SourceProperty ||
+             avProp == StretchProperty ||
+             avProp == StretchDirectionProperty ||
+             avProp == IterationCountProperty) && _customVisual is not null)
+        {
+            _customVisual.SendHandlerMessage(
+                new GifDrawPayload(
+                    HandlerCommand.Update,
+                    null,
+                    GetGifSize(),
+                    Bounds.Size,
+                    Stretch,
+                    StretchDirection,
+                    IterationCount));
+        }
+    }
+
+    private void InitializeGif()
+    {
+        Stop();
+        DisposeImpl();
+
+        var elemVisual = ElementComposition.GetElementVisual(this);
+        var compositor = elemVisual?.Compositor;
+
+        if (compositor is null)
+        {
+            return;
+        }
+
+        _customVisual = compositor.CreateCustomVisual(new GifCompositionCustomVisualHandler());
+
+        ElementComposition.SetElementChildVisual(this, _customVisual);
+
+        LayoutUpdated += OnLayoutUpdated;
+
+        _customVisual.Size = new Vector2((float)Bounds.Size.Width, (float)Bounds.Size.Height);
+
+        using var stream = AssetLoader.Open(Source);
+        using var tempGifDecoder = new GifDecoder(stream, CancellationToken.None);
+        _gifHeight = tempGifDecoder.Size.Height;
+        _gifWidth = tempGifDecoder.Size.Width;
+
+        _customVisual?.SendHandlerMessage(
+            new GifDrawPayload(
+                HandlerCommand.Start,
+                Source,
+                GetGifSize(),
+                Bounds.Size,
+                Stretch,
+                StretchDirection,
+                IterationCount));
+
+        InvalidateVisual();
+    }
+
+    /// <inheritdoc />
+    protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+    {
+        base.OnDetachedFromVisualTree(e);
+        LayoutUpdated -= OnLayoutUpdated;
+
+        Stop();
+        DisposeImpl();
+    }
+
+
+    private void OnLayoutUpdated(object? sender, EventArgs e)
+    {
+        if (_customVisual == null)
+        {
+            return;
+        }
+
+        _customVisual.Size = new Vector2((float)Bounds.Size.Width, (float)Bounds.Size.Height);
+
+        _customVisual.SendHandlerMessage(
+            new GifDrawPayload(
+                HandlerCommand.Update,
+                null,
+                GetGifSize(),
+                Bounds.Size,
+                Stretch,
+                StretchDirection,
+                IterationCount));
+    }
+
+    private void Stop()
+    {
+        _customVisual?.SendHandlerMessage(new GifDrawPayload(HandlerCommand.Stop));
+    }
+
+    private void DisposeImpl()
+    {
+        _customVisual?.SendHandlerMessage(new GifDrawPayload(HandlerCommand.Dispose));
+        _customVisual = null;
+    }
+}
diff --git a/Avalonia.Labs.Gif/GifInstance.cs b/Avalonia.Labs.Gif/GifInstance.cs
new file mode 100644
index 0000000000000000000000000000000000000000..05fed9ec99cc4cdd093c82242323d846bbe43579
--- /dev/null
+++ b/Avalonia.Labs.Gif/GifInstance.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using Avalonia.Animation;
+using Avalonia.Labs.Gif.Decoding;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+
+namespace Avalonia.Labs.Gif;
+
+internal class GifInstance : IDisposable
+{
+    public IterationCount IterationCount { get; set; }
+    private readonly GifDecoder _gifDecoder;
+    private readonly WriteableBitmap? _targetBitmap;
+    private TimeSpan _totalTime;
+    private readonly List<TimeSpan>? _frameTimes;
+    private uint _iterationCount;
+    private int _currentFrameIndex;
+
+    private CancellationTokenSource CurrentCts { get; }
+
+    internal GifInstance(object newValue) : this(newValue switch
+    {
+        Stream s => s, 
+        _ => throw new InvalidDataException("Unsupported source object")
+    })
+    {
+    }
+    
+    public GifInstance(Uri uri) : this(AssetLoader.Open(uri))
+    {
+    }
+
+    private GifInstance(Stream currentStream)
+    {
+        if (!currentStream.CanSeek)
+            throw new InvalidDataException("The provided stream is not seekable.");
+
+        if (!currentStream.CanRead)
+            throw new InvalidOperationException("Can't read the stream provided.");
+
+        currentStream.Seek(0, SeekOrigin.Begin);
+
+        CurrentCts = new CancellationTokenSource();
+
+        _gifDecoder = new GifDecoder(currentStream, CurrentCts.Token);
+
+        if (_gifDecoder.Header is null)
+            return;
+
+        var pixSize = new PixelSize(_gifDecoder.Header.Dimensions.Width, _gifDecoder.Header.Dimensions.Height);
+
+        _targetBitmap = new WriteableBitmap(pixSize, new Vector(96, 96), PixelFormat.Bgra8888, AlphaFormat.Opaque);
+        GifPixelSize = pixSize;
+
+        _totalTime = TimeSpan.Zero;
+
+        _frameTimes = _gifDecoder._frames.Select(frame =>
+        {
+            _totalTime = _totalTime.Add(frame.FrameDelay);
+            return _totalTime;
+        }).ToList();
+
+        _gifDecoder.RenderFrame(0, _targetBitmap);
+    }
+
+    public PixelSize GifPixelSize { get; }
+    private bool _isDisposed;
+
+    public void Dispose()
+    {
+        if (_isDisposed) return;
+
+        GC.SuppressFinalize(this);
+
+        _isDisposed = true;
+        CurrentCts.Cancel();
+        _targetBitmap?.Dispose();
+    }
+
+    public WriteableBitmap? ProcessFrameTime(TimeSpan elapsed)
+    {
+        if (!IterationCount.IsInfinite && _iterationCount > IterationCount.Value)
+        {
+            return null;
+        }
+
+        if (CurrentCts.IsCancellationRequested)
+        {
+            return null;
+        }
+
+        if (_frameTimes is null)
+            return null;
+
+        var totalTicks = _totalTime.Ticks;
+
+        if (totalTicks == 0)
+        {
+            return ProcessFrameIndex(0);
+        }
+
+
+        var elapsedTicks = elapsed.Ticks;
+        var timeModulus = TimeSpan.FromTicks(elapsedTicks % totalTicks);
+        var targetFrame = _frameTimes.FirstOrDefault(x => timeModulus < x);
+        var currentFrame = _frameTimes.IndexOf(targetFrame);
+        if (currentFrame == -1) currentFrame = 0;
+
+        if (_currentFrameIndex == currentFrame)
+            return _targetBitmap;
+
+        _iterationCount = (uint)(elapsedTicks / totalTicks);
+
+        return ProcessFrameIndex(currentFrame);
+    }
+
+    private WriteableBitmap? ProcessFrameIndex(int frameIndex)
+    {
+        _gifDecoder.RenderFrame(frameIndex, _targetBitmap);
+        _currentFrameIndex = frameIndex;
+
+        return _targetBitmap;
+    }
+}
diff --git a/Avalonia.Labs.Gif/HandlerCommand.cs b/Avalonia.Labs.Gif/HandlerCommand.cs
new file mode 100644
index 0000000000000000000000000000000000000000..322773016b53a2d535f45be3b1c5e2322b18366d
--- /dev/null
+++ b/Avalonia.Labs.Gif/HandlerCommand.cs
@@ -0,0 +1,9 @@
+namespace Avalonia.Labs.Gif;
+
+internal enum HandlerCommand
+{
+    Start,
+    Stop,
+    Update,
+    Dispose
+}
\ No newline at end of file
diff --git a/Blacklight.sln b/Blacklight.sln
index 84453fdf82826b334aaa455d1f5bf8f88dd90395..de94322bb1ddca89d0631b73a47a4ceaae0bccb5 100644
--- a/Blacklight.sln
+++ b/Blacklight.sln
@@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Blacklight.Desktop", "Black
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lightquark.NET", "Lightquark.NET\Lightquark.NET.csproj", "{354D4E75-A2CE-46D0-BB81-778ADA819951}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Labs.Gif", "Avalonia.Labs.Gif\Avalonia.Labs.Gif.csproj", "{354D4E75-A2CE-46D0-BB81-778ADADA9951}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -24,5 +26,7 @@ Global
 		{354D4E75-A2CE-46D0-BB81-778ADA819951}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{354D4E75-A2CE-46D0-BB81-778ADA819951}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{354D4E75-A2CE-46D0-BB81-778ADA819951}.Release|Any CPU.Build.0 = Release|Any CPU
+		{354D4E75-A2CE-46D0-BB81-778ADADA9951}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{354D4E75-A2CE-46D0-BB81-778ADADA9951}.Debug|Any CPU.Build.0 = Debug|Any CPU
 	EndGlobalSection
 EndGlobal
diff --git a/Blacklight/App.axaml b/Blacklight/App.axaml
index 15fe671f28eaf387aff2e87ed30d9cb03e306a9c..abc01e617299c8294a6e9532241f3672e393349c 100644
--- a/Blacklight/App.axaml
+++ b/Blacklight/App.axaml
@@ -35,7 +35,6 @@
 									<TextBlock Margin="6,0,0,0" FontSize="14" VerticalAlignment="Center">Emilia</TextBlock>
 								</StackPanel>
 
-
 								<Button x:Name="PART_MenuButton" IsVisible="False" />
 								<Button x:Name="PART_PinButton" IsVisible="False" />
 								<Button x:Name="PART_MaximizeRestoreButton" IsVisible="False" />
diff --git a/Blacklight/App.axaml.cs b/Blacklight/App.axaml.cs
index cf3566292f7ccbc7188da18eebe06ff9afe8340d..aa5af35c43e7cdab672fcc766b586b8e795d6cd4 100644
--- a/Blacklight/App.axaml.cs
+++ b/Blacklight/App.axaml.cs
@@ -1,4 +1,5 @@
 using System;
+using System.IO;
 using Avalonia;
 using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
@@ -26,12 +27,12 @@ public partial class App : Application
     {
         var services = new ServiceCollection();
 
-        services.AddTransient<MainWindowViewModel>();
+        services.AddSingleton<MainWindowViewModel>();
         services.AddSingleton<ViewNavigationService>();
         services.AddSingleton<Client>();
         
-        services.AddTransient<ClientViewModel>();
-        services.AddTransient<LoginViewModel>();
+        services.AddSingleton<ClientViewModel>();
+        services.AddSingleton<LoginViewModel>();
         
         var serviceProvider = services.BuildServiceProvider();
         Services = serviceProvider;
@@ -43,6 +44,15 @@ public partial class App : Application
         client.OnLogOut = () =>
         {
             mainWindowViewModel.CurrentViewModel = loginViewModel;
+            var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json");
+            try
+            {
+                File.Delete(path);
+            }
+            catch (Exception ex)
+            {
+                Console.Error.WriteLine(ex);
+            }
         };
         var clientViewModel = serviceProvider.GetRequiredService<ClientViewModel>();
         
diff --git a/Blacklight/Assets/loading.gif b/Blacklight/Assets/loading.gif
new file mode 100644
index 0000000000000000000000000000000000000000..5261a5890e94a67ff11926df82c841715df3989b
Binary files /dev/null and b/Blacklight/Assets/loading.gif differ
diff --git a/Blacklight/Blacklight.csproj b/Blacklight/Blacklight.csproj
index 1322419e296f429ac9bc967cfc71553cbf0be1a1..8e96b249df97cc95269cc5b0919a2c73892fbfbd 100644
--- a/Blacklight/Blacklight.csproj
+++ b/Blacklight/Blacklight.csproj
@@ -37,6 +37,7 @@
 
 
     <ItemGroup>
+      <ProjectReference Include="..\Avalonia.Labs.Gif\Avalonia.Labs.Gif.csproj" />
       <ProjectReference Include="..\Lightquark.NET\Lightquark.NET.csproj" />
     </ItemGroup>
 </Project>
diff --git a/Blacklight/ViewModels/LoginViewModel.cs b/Blacklight/ViewModels/LoginViewModel.cs
index e39a51a9ef92c61c163fb07f7e855365402fb49e..5c3c558568816152d0046aaa32578349a61618f8 100644
--- a/Blacklight/ViewModels/LoginViewModel.cs
+++ b/Blacklight/ViewModels/LoginViewModel.cs
@@ -1,16 +1,20 @@
 using System;
+using System.IO;
+using System.Threading;
 using System.Windows.Input;
+using Avalonia.Threading;
 using Blacklight.Util;
 using Blacklight.Views.Login;
 using CommunityToolkit.Mvvm.Input;
 using Lightquark.NET;
 using Lightquark.Types;
+using Newtonsoft.Json;
 
 namespace Blacklight.ViewModels;
 
 public class LoginViewModel : ViewModelBase
 {
-    private object _currentControl = new MainLoginPrompt();
+    private object _currentControl = new Loading();
     private readonly ViewNavigationService _nav;
     public string Network { get; set; } = "lightquark.network";
 
@@ -26,6 +30,7 @@ public class LoginViewModel : ViewModelBase
     private string _email = string.Empty;
     private string _password = string.Empty;
     private string? _loginError;
+    private string _loadingText = "Loading...";
 
     public string Email
     {
@@ -64,7 +69,43 @@ public class LoginViewModel : ViewModelBase
         _nav = nav;
         Client = client;
         Status = "Get network";
-        SwitchNetwork();
+        var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json");
+        if (Path.Exists(path))
+        {
+            try
+            {
+                var json = File.ReadAllText(path);
+                var persistentData = JsonConvert.DeserializeObject<LoginViewModelPersistent>(json);
+                LoadExistingData(persistentData!);
+            }
+            catch (Exception ex)
+            {
+                Console.Error.WriteLine(ex);
+            }
+        }
+        else
+        {
+            SwitchNetwork();
+            _currentControl = new MainLoginPrompt();
+        }
+    }
+
+    public async void LoadExistingData(LoginViewModelPersistent persistentData)
+    {
+        LoadingText = "Connecting to network";
+        var network = await Client.GetNetworkAsync(new Uri(persistentData.NetworkBase));
+        if (network.success)
+        {
+            Client.NetworkInformation = network.networkInformation;
+            Client.UseToken(persistentData.AccessToken, persistentData.RefreshToken);
+            Console.WriteLine("Found token, showing ClientView");
+            _nav.ShowView<ClientViewModel>();
+            _currentControl = new MainLoginPrompt();
+        }
+        else
+        {
+            LoadingText = $"Failed to connect to network :<  ({network.error})";
+        }
     }
     
     public object CurrentControl
@@ -80,8 +121,17 @@ public class LoginViewModel : ViewModelBase
     public ICommand UseEquinoxCommand => new RelayCommand(UseEquinox);
     public ICommand UseDevCommand => new RelayCommand(UseDev);
     public ICommand SwitchNetworkCommand => new RelayCommand(SwitchNetwork);
+
+
+
     public ICommand WaitNvmCommand => new RelayCommand(WaitNvm);
 
+    public string LoadingText
+    {
+        get => _loadingText;
+        set => SetProperty(ref _loadingText, value);
+    }
+
 
     private async void Login()
     {
@@ -97,6 +147,15 @@ public class LoginViewModel : ViewModelBase
         var res = await Client.AcquireToken(Email, Password);
         if (res.success)
         {
+            var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "login.json");
+            Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+            await File.WriteAllTextAsync(path, JsonConvert.SerializeObject(new LoginViewModelPersistent
+            {
+                NetworkBase = Network,
+                AccessToken = res.accessToken!,
+                RefreshToken = res.refreshToken!
+            }));
+            Console.WriteLine("Logged in, showing ClientView");
             _nav.ShowView<ClientViewModel>();
         }
         else
@@ -156,6 +215,11 @@ public class LoginViewModel : ViewModelBase
         }
         EnableButtons = true;
     }
+}
 
-    
+public record LoginViewModelPersistent
+{
+    public required string AccessToken;
+    public required string RefreshToken;
+    public required string NetworkBase;
 }
\ No newline at end of file
diff --git a/Blacklight/ViewModels/MainWindowViewModel.cs b/Blacklight/ViewModels/MainWindowViewModel.cs
index 3c0333c815feb9a3609501c36ffe9075d045097d..653631d633019fe6d38f2d1d70a603d844a98151 100644
--- a/Blacklight/ViewModels/MainWindowViewModel.cs
+++ b/Blacklight/ViewModels/MainWindowViewModel.cs
@@ -1,7 +1,12 @@
 using System;
+using System.IO;
+using Avalonia.Controls.Shapes;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
 using Blacklight.Util;
 using Lightquark.NET;
 using Microsoft.Extensions.DependencyInjection;
+using Path = System.IO.Path;
 
 namespace Blacklight.ViewModels;
 
@@ -17,21 +22,43 @@ public class MainWindowViewModel : ViewModelBase
         }
     }
 
+    private Bitmap? _backgroundImage;
+    
+    public Bitmap BackgroundImage
+    {
+        get
+        {
+            if (_backgroundImage != null) return _backgroundImage;
+
+            var path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight", "background.png");
+            if (!Path.Exists(path))
+            {
+                _backgroundImage = new Bitmap(AssetLoader.Open(new Uri("avares://Blacklight/Assets/hime.png")));
+                return _backgroundImage;
+            }
+
+            _backgroundImage = new Bitmap(path);
+            return _backgroundImage;
+        }
+    }
+
     private object _currentViewModel = new LoadingViewModel();
 
     public MainWindowViewModel(ViewNavigationService nav)
     {
+        Directory.CreateDirectory(Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight"));
         nav.SetMainWindowViewModel(this);
     }
 
     public MainWindowViewModel()
     {
+        Directory.CreateDirectory(Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "blacklight"));
         var services = new ServiceCollection();
-        services.AddTransient<MainWindowViewModel>(_ => this);
+        services.AddSingleton<MainWindowViewModel>(_ => this);
         services.AddSingleton<Client>();
         services.AddSingleton<ViewNavigationService>();
-        services.AddTransient<ClientViewModel>();
-        services.AddTransient<LoginViewModel>();
+        services.AddSingleton<ClientViewModel>();
+        services.AddSingleton<LoginViewModel>();
 
         var serviceProvider = services.BuildServiceProvider();
         App.Services = serviceProvider;
diff --git a/Blacklight/Views/Login/Loading.axaml b/Blacklight/Views/Login/Loading.axaml
new file mode 100644
index 0000000000000000000000000000000000000000..3f36c7a88333c1bbd9fd25c45c9d0f96808757e1
--- /dev/null
+++ b/Blacklight/Views/Login/Loading.axaml
@@ -0,0 +1,21 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:viewModels="clr-namespace:Blacklight.ViewModels"
+             xmlns:gif="clr-namespace:Avalonia.Labs.Gif;assembly=Avalonia.Labs.Gif"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="Blacklight.Views.Login.Loading"
+             x:DataType="viewModels:LoginViewModel">
+	<StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
+		<gif:GifImage HorizontalAlignment="Center" VerticalAlignment="Center" Source="avares://Blacklight/Assets/loading.gif" >
+			<gif:GifImage.Effect>
+				<DropShadowEffect Color="#FFFFFF"
+				                  BlurRadius="50"
+				                  OffsetX="0" OffsetY="0"
+				                  Opacity="0.5"/>
+			</gif:GifImage.Effect>
+		</gif:GifImage>
+		<TextBlock HorizontalAlignment="Center" Text="{Binding LoadingText, FallbackValue='Loading...'}" />
+	</StackPanel>
+</UserControl>
diff --git a/Blacklight/Views/Login/Loading.axaml.cs b/Blacklight/Views/Login/Loading.axaml.cs
new file mode 100644
index 0000000000000000000000000000000000000000..4d31b732e36fc88623fc98c0c187383bc14d6acf
--- /dev/null
+++ b/Blacklight/Views/Login/Loading.axaml.cs
@@ -0,0 +1,13 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace Blacklight.Views.Login;
+
+public partial class Loading : UserControl
+{
+    public Loading()
+    {
+        InitializeComponent();
+    }
+}
\ No newline at end of file
diff --git a/Blacklight/Views/MainWindow.axaml b/Blacklight/Views/MainWindow.axaml
index bee0cffcaa287321f4603909ff424917f54b9047..93cddb0bbee85011b47940735df30d6dd25e4fb3 100644
--- a/Blacklight/Views/MainWindow.axaml
+++ b/Blacklight/Views/MainWindow.axaml
@@ -19,7 +19,7 @@
 	</Design.DataContext>
 
 	<Window.Background>
-		<ImageBrush Stretch="UniformToFill" Source="avares://Blacklight/Assets/hime.png"></ImageBrush>
+		<ImageBrush Stretch="UniformToFill" Source="{Binding BackgroundImage}"></ImageBrush>
 	</Window.Background>
 
 	<Window.Resources>
diff --git a/Lightquark.NET/Client.cs b/Lightquark.NET/Client.cs
index 307ef95b6afddcac4fb479198e5989dc287c5054..28731d306fa971841f78f101a1ae37768e6f9b3e 100644
--- a/Lightquark.NET/Client.cs
+++ b/Lightquark.NET/Client.cs
@@ -59,6 +59,7 @@ public partial class Client : ObservableObject
             var expiryTime = Base36Converter.ConvertFrom(expirationTimePart);
             var origin = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero);
             var expiryDate = origin.AddMilliseconds(expiryTime);
+            Console.WriteLine($"Token expires at {expiryDate}");
             var refreshTime = expiryDate.Subtract(TimeSpan.FromMinutes(15));
             var timeUntilRefresh = refreshTime - DateTimeOffset.Now;
             if (timeUntilRefresh < TimeSpan.FromSeconds(1))
diff --git a/Lightquark.NET/ClientMethods/Login.cs b/Lightquark.NET/ClientMethods/Login.cs
index 676347c410b944cc6fa1a13c7031d674b9048212..9b77f8ef6a14475bde3d64a18ca70601bb56fb48 100644
--- a/Lightquark.NET/ClientMethods/Login.cs
+++ b/Lightquark.NET/ClientMethods/Login.cs
@@ -29,8 +29,7 @@ public partial class Client
             switch (res.StatusCode)
             {
                 case HttpStatusCode.OK:
-                    RefreshToken = parsedRes.Response.RefreshToken;
-                    AccessToken = parsedRes.Response.AccessToken;
+                    UseToken(parsedRes.Response.AccessToken!, parsedRes.Response.RefreshToken!);
                     return (true, string.Empty, AccessToken, RefreshToken);
                 case HttpStatusCode.BadRequest:
                 case HttpStatusCode.Forbidden:
@@ -47,6 +46,12 @@ public partial class Client
         }
     }
 
+    public async void UseToken(string accessToken, string refreshToken)
+    {
+        RefreshToken = refreshToken;
+        AccessToken = accessToken;
+    }
+
     private async void AcquireToken(object? state = null)
     {
         Console.WriteLine($"Refreshing token {AccessToken} {RefreshToken}");