Introduction
Photo collage applications appear simple from the outside.
A user selects several images, chooses a layout, adjusts the spacing, and downloads the final result.
However, a reliable browser-based editor must solve several technical problems:
Reading multiple large image files without freezing the interface
Validating image formats and dimensions
Generating grid, magazine, and long-image layouts
Cropping images without distorting them
Supporting drag, resize, reorder, and zoom interactions
Rendering sharply on high-density displays
Exporting PNG and JPEG files
Preserving project state between browser sessions
Releasing object URLs and image memory correctly
Keeping private photos inside the browser
This article builds a privacy-first collage editor with Blazor WebAssembly, HTML Canvas, and JavaScript interop.
The core image workflow runs entirely in the browser. Images do not need to be uploaded to an ASP.NET Core server before they can be arranged or exported.
Products in the pic collage category demonstrate the value of a focused editor that lets users combine several photos quickly without opening a large professional design application.
The implementation below is an independent technical example. It does not describe the internal architecture of any existing product.
1. Why Use Blazor WebAssembly?
Blazor WebAssembly allows C# code to run directly inside the browser.
That makes it suitable for managing:
Project state
Layout rules
Image metadata
User controls
Undo and redo history
Export settings
Local persistence
HTML Canvas remains useful for image rendering because the browser already provides highly optimized APIs for:
Drawing images
Cropping
Scaling
Clipping
Applying transforms
Exporting image files
JavaScript interop connects the Blazor state layer with the Canvas rendering layer.
The architecture looks like this:
Blazor Components
│
▼
C# Project & Layout Models
│
▼
Canvas Rendering Service
│
▼
JavaScript Interop Module
│
▼
HTML Canvas
│
▼
PNG / JPEG Export
All source images remain local to the browser.
2. Create the Blazor WebAssembly Project
Create a standalone Blazor WebAssembly application:
dotnet new blazorwasm -n PrivacyFirstCollage
cd PrivacyFirstCollage
A possible project structure is:
PrivacyFirstCollage
├── Components
├── Models
├── Services
├── Layouts
├── Pages
├── wwwroot
│ └── js
│ └── collageCanvas.js
└── Program.cs
The application contains two primary layers.
C# manages project state and layout calculations.
JavaScript decodes images, renders the HTML Canvas, and exports the final image.
3. Define the Project Model
Create Models/CollageProject.cs
namespace PrivacyFirstCollage.Models;
public sealed class CollageProject
{
public Guid Id { get; init; }
= Guid.NewGuid();
public string Name { get; set; }
= "Untitled Collage";
public int CanvasWidth { get; set; }
= 1200;
public int CanvasHeight { get; set; }
= 1200;
public string BackgroundColor { get; set; }
= "#ffffff";
public double Gap { get; set; }
= 12;
public double OuterPadding { get; set; }
= 12;
public LayoutType Layout { get; set; }
= LayoutType.Grid;
public List<CollageItem> Items { get; set; }
= new();
public DateTimeOffset UpdatedAt { get; set; }
= DateTimeOffset.UtcNow;
}
Create Models/LayoutType.cs
namespace PrivacyFirstCollage.Models;
public enum LayoutType
{
Grid,
Freeform,
VerticalLongImage,
HorizontalLongImage
}
The project stores logical values rather than Canvas-specific objects.
This separation allows the same project to be:
Rendered at different resolutions
Saved to IndexedDB
Restored later
Exported as JSON
Used by another rendering engine
4. Define the Image Item Model
Create Models/CollageItem.cs
namespace PrivacyFirstCollage.Models;
public sealed class CollageItem
{
public Guid Id { get; init; }
= Guid.NewGuid();
public required Guid AssetId { get; init; }
public double X { get; set; }
public double Y { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public double RotationDegrees { get; set; }
public double Scale { get; set; }
= 1;
public double OffsetX { get; set; }
public double OffsetY { get; set; }
public double Opacity { get; set; }
= 1;
public double BorderRadius { get; set; }
public int ZIndex { get; set; }
public ImageFitMode FitMode { get; set; }
= ImageFitMode.Cover;
}
Create Models/ImageFitMode.cs
namespace PrivacyFirstCollage.Models;
public enum ImageFitMode
{
Cover,
Contain,
Stretch
}
The rectangle properties define the image frame.
The OffsetX, OffsetY, and Scale properties determine how the original image is positioned within that frame.
This distinction is important.
Moving the frame and moving the image inside the frame are two different editing operations.
5. Define the Local Image Asset
Each uploaded image should be represented by a lightweight model that stores its metadata rather than the image data itself.
Create Models/ImageAsset.cs:
namespace PrivacyFirstCollage.Models;
public sealed class ImageAsset
{
public Guid Id { get; init; }
= Guid.NewGuid();
public required string FileName { get; init; }
public required string ContentType { get; init; }
public required string ObjectUrl { get; init; }
public required int NaturalWidth { get; init; }
public required int NaturalHeight { get; init; }
public long FileSize { get; init; }
}
The ObjectUrl contains a temporary browser URL created with URL.createObjectURL().
Using an object URL instead of converting the image into Base64 provides several advantages:
Lower memory usage
Faster loading
No unnecessary duplication of image data
Better performance for large images
6. Register the Application Services
Update Program.cs:
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using PrivacyFirstCollage;
using PrivacyFirstCollage.Layouts;
using PrivacyFirstCollage.Services;
var builder =
WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>(
"head::after");
builder.Services.AddScoped<ImageAssetService>();
builder.Services.AddScoped<CanvasRenderService>();
builder.Services.AddScoped<ProjectStorageService>();
builder.Services.AddScoped<GridLayoutEngine>();
builder.Services.AddScoped<LongImageLayoutEngine>();
await builder.Build().RunAsync();
The application is composed of several focused services.
| Service | Responsibility |
|---|---|
| ImageAssetService | Loads and validates uploaded images |
| CanvasRenderService | Renders the collage onto the HTML Canvas |
| ProjectStorageService | Persists project data |
| GridLayoutEngine | Generates grid layouts |
| LongImageLayoutEngine | Generates long-image layouts |
Keeping these responsibilities separate makes the application easier to test and maintain.
7. Create the JavaScript Canvas Module
Create wwwroot/js/collageCanvas.js:
const imageCache = new Map();
export async function createObjectUrl(
streamReference,
contentType) {
const buffer =
await streamReference.arrayBuffer();
const blob =
new Blob(
[buffer],
{ type: contentType });
return URL.createObjectURL(blob);
}
export function revokeObjectUrl(url) {
URL.revokeObjectURL(url);
imageCache.delete(url);
}
export async function getImageMetadata(url) {
const image =
await loadImage(url);
return {
width: image.naturalWidth,
height: image.naturalHeight
};
}
async function loadImage(url) {
if (imageCache.has(url)) {
return imageCache.get(url);
}
const image =
new Image();
image.decoding = "async";
const loaded =
new Promise((resolve, reject) => {
image.onload = () => resolve(image);
image.onerror = reject;
});
image.src = url;
await loaded;
imageCache.set(url, image);
return image;
}
Why Cache Images?
The module maintains an in-memory cache of decoded images.
Without caching:
Every render would decode the image again.
CPU usage would increase significantly.
Scrolling and dragging would become noticeably slower.
By caching decoded image elements, the browser only performs image decoding once.
8. Create the Image Asset Service
Create Services/ImageAssetService.cs:
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using PrivacyFirstCollage.Models;
namespace PrivacyFirstCollage.Services;
public sealed class ImageAssetService
: IAsyncDisposable
{
private const long MaxFileSize =
20 * 1024 * 1024;
private static readonly HashSet<string>
AllowedContentTypes =
new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg",
"image/png",
"image/webp"
};
private readonly IJSRuntime _jsRuntime;
private IJSObjectReference? _module;
private readonly List<string>
_objectUrls = new();
public ImageAssetService(
IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<ImageAsset> CreateAsync(
IBrowserFile file)
{
if (file.Size <= 0)
{
throw new InvalidDataException(
"The selected image is empty.");
}
if (file.Size > MaxFileSize)
{
throw new InvalidDataException(
"The image exceeds the 20 MB limit.");
}
if (!AllowedContentTypes.Contains(
file.ContentType))
{
throw new InvalidDataException(
"Only JPEG, PNG, and WebP files are supported.");
}
var module =
await GetModuleAsync();
await using var stream =
file.OpenReadStream(MaxFileSize);
using var streamReference =
new DotNetStreamReference(stream);
var objectUrl =
await module.InvokeAsync<string>(
"createObjectUrl",
streamReference,
file.ContentType);
var metadata =
await module.InvokeAsync<ImageMetadata>(
"getImageMetadata",
objectUrl);
ValidateDimensions(
metadata.Width,
metadata.Height);
_objectUrls.Add(objectUrl);
return new ImageAsset
{
FileName = file.Name,
ContentType = file.ContentType,
ObjectUrl = objectUrl,
NaturalWidth = metadata.Width,
NaturalHeight = metadata.Height,
FileSize = file.Size
};
}
private static void ValidateDimensions(
int width,
int height)
{
if (width <= 0 || height <= 0)
{
throw new InvalidDataException(
"The image dimensions are invalid.");
}
const long maxPixels =
40_000_000;
var pixelCount =
(long)width * height;
if (pixelCount > maxPixels)
{
throw new InvalidDataException(
"The image dimensions are too large.");
}
}
private async Task<IJSObjectReference>
GetModuleAsync()
{
_module ??=
await _jsRuntime.InvokeAsync<
IJSObjectReference>(
"import",
"./js/collageCanvas.js");
return _module;
}
public async ValueTask DisposeAsync()
{
if (_module is null)
{
return;
}
foreach (var url in _objectUrls)
{
await _module.InvokeVoidAsync(
"revokeObjectUrl",
url);
}
await _module.DisposeAsync();
}
private sealed record ImageMetadata(
int Width,
int Height);
}
Why Validate More Than File Size?
Checking only the uploaded file size is not sufficient.
A highly compressed image can occupy only a few megabytes on disk while expanding into hundreds of megabytes after decoding.
The service therefore validates both:
File size
Pixel count
This helps protect the application from excessive memory consumption and potential browser crashes.
Resource Cleanup
The service implements IAsyncDisposable to ensure all temporary browser resources are released.
During disposal it:
Revokes every object URL.
Clears browser references.
Disposes the imported JavaScript module.
Proper cleanup is essential for long-running browser applications that may load hundreds of images during a single editing session.
9. Add an Image Upload Component
The collage editor needs a component that accepts multiple images, validates them, and creates ImageAsset objects for each uploaded file.
Create Components/ImageUploader.razor:
@using Microsoft.AspNetCore.Components.Forms
@using PrivacyFirstCollage.Models
@inject ImageAssetService AssetService
<InputFile
OnChange="HandleFilesAsync"
multiple
accept="image/jpeg,image/png,image/webp" />
@if (!string.IsNullOrWhiteSpace(_error))
{
<p class="upload-error">@_error</p>
}
@code {
[Parameter]
public EventCallback<IReadOnlyList<ImageAsset>>
AssetsCreated { get; set; }
private string? _error;
private async Task HandleFilesAsync(
InputFileChangeEventArgs eventArgs)
{
_error = null;
var created =
new List<ImageAsset>();
foreach (var file in
eventArgs.GetMultipleFiles(20))
{
try
{
var asset =
await AssetService.CreateAsync(file);
created.Add(asset);
}
catch (Exception exception)
{
_error =
$"{file.Name}: {exception.Message}";
}
}
if (created.Count > 0)
{
await AssetsCreated.InvokeAsync(created);
}
}
}
The browser can accept several files in a single selection.
A production-quality application should still define reasonable limits for:
Maximum number of uploaded images
Maximum file size per image
Total project memory usage
Maximum export resolution
Applying these limits early prevents excessive memory consumption and improves the overall user experience.
10. Build a Grid Layout Engine
Rather than positioning images directly during rendering, the application first calculates a logical layout.
Create Layouts/GridLayoutEngine.cs:
using PrivacyFirstCollage.Models;
namespace PrivacyFirstCollage.Layouts;
public sealed class GridLayoutEngine
{
public IReadOnlyList<CollageItem>
CreateLayout(
IReadOnlyList<ImageAsset> assets,
int canvasWidth,
int canvasHeight,
int columns,
double gap,
double outerPadding)
{
if (assets.Count == 0)
{
return Array.Empty<CollageItem>();
}
columns =
Math.Clamp(
columns,
1,
assets.Count);
var rows =
(int)Math.Ceiling(
assets.Count /
(double)columns);
var availableWidth =
canvasWidth
- (outerPadding * 2)
- (gap * (columns - 1));
var availableHeight =
canvasHeight
- (outerPadding * 2)
- (gap * (rows - 1));
var cellWidth =
availableWidth / columns;
var cellHeight =
availableHeight / rows;
var items =
new List<CollageItem>(
assets.Count);
for (var index = 0;
index < assets.Count;
index++)
{
var column =
index % columns;
var row =
index / columns;
items.Add(
new CollageItem
{
AssetId =
assets[index].Id,
X =
outerPadding
+ column * (cellWidth + gap),
Y =
outerPadding
+ row * (cellHeight + gap),
Width = cellWidth,
Height = cellHeight,
ZIndex = index,
FitMode = ImageFitMode.Cover
});
}
return items;
}
}
Why Separate Layout from Rendering?
The layout engine performs no Canvas drawing.
Its only responsibility is to calculate the position and size of each collage item.
Separating layout logic from rendering offers several benefits:
Easier unit testing
Simpler maintenance
Reusable layout algorithms
Independent rendering implementations
Whether the project is rendered on an HTML Canvas, SVG, SkiaSharp, or another graphics engine, the layout calculations remain unchanged.
11. Test the Grid Layout Engine
Because the layout engine contains only business logic, it can be tested with standard unit tests.
Example xUnit test:
using PrivacyFirstCollage.Layouts;
using PrivacyFirstCollage.Models;
namespace PrivacyFirstCollage.Tests;
public sealed class GridLayoutEngineTests
{
[Fact]
public void CreatesTwoByTwoGrid()
{
var assets =
Enumerable.Range(0, 4)
.Select(index =>
new ImageAsset
{
FileName =
$"image-{index}.jpg",
ContentType =
"image/jpeg",
ObjectUrl =
$"blob:image-{index}",
NaturalWidth = 1200,
NaturalHeight = 800
})
.ToList();
var engine =
new GridLayoutEngine();
var result =
engine.CreateLayout(
assets,
canvasWidth: 1000,
canvasHeight: 1000,
columns: 2,
gap: 20,
outerPadding: 20);
Assert.Equal(
4,
result.Count);
Assert.All(
result,
item =>
{
Assert.Equal(
470,
item.Width);
Assert.Equal(
470,
item.Height);
});
}
}
By testing layout calculations independently, rendering bugs become much easier to isolate.
12. Build a Long-Image Layout Engine
Long-image layouts preserve each source image's aspect ratio while stitching multiple images together.
Create Layouts/LongImageLayoutEngine.cs:
using PrivacyFirstCollage.Models;
namespace PrivacyFirstCollage.Layouts;
public sealed class LongImageLayoutEngine
{
public LongImageLayoutResult CreateVertical(
IReadOnlyList<ImageAsset> assets,
int targetWidth,
double gap,
double outerPadding)
{
var contentWidth =
targetWidth
- (outerPadding * 2);
var items =
new List<CollageItem>(
assets.Count);
var currentY =
outerPadding;
for (var index = 0;
index < assets.Count;
index++)
{
var asset =
assets[index];
var aspectRatio =
asset.NaturalWidth /
(double)asset.NaturalHeight;
var height =
contentWidth / aspectRatio;
items.Add(
new CollageItem
{
AssetId = asset.Id,
X = outerPadding,
Y = currentY,
Width = contentWidth,
Height = height,
FitMode = ImageFitMode.Contain,
ZIndex = index
});
currentY +=
height + gap;
}
var canvasHeight =
assets.Count == 0
? (int)(outerPadding * 2)
: (int)Math.Ceiling(
currentY
- gap
+ outerPadding);
return new LongImageLayoutResult(
targetWidth,
canvasHeight,
items);
}
public LongImageLayoutResult CreateHorizontal(
IReadOnlyList<ImageAsset> assets,
int targetHeight,
double gap,
double outerPadding)
{
var contentHeight =
targetHeight
- (outerPadding * 2);
var items =
new List<CollageItem>(
assets.Count);
var currentX =
outerPadding;
for (var index = 0;
index < assets.Count;
index++)
{
var asset =
assets[index];
var aspectRatio =
asset.NaturalWidth /
(double)asset.NaturalHeight;
var width =
contentHeight * aspectRatio;
items.Add(
new CollageItem
{
AssetId = asset.Id,
X = currentX,
Y = outerPadding,
Width = width,
Height = contentHeight,
FitMode = ImageFitMode.Contain,
ZIndex = index
});
currentX +=
width + gap;
}
var canvasWidth =
assets.Count == 0
? (int)(outerPadding * 2)
: (int)Math.Ceiling(
currentX
- gap
+ outerPadding);
return new LongImageLayoutResult(
canvasWidth,
targetHeight,
items);
}
}
public sealed record LongImageLayoutResult(
int CanvasWidth,
int CanvasHeight,
IReadOnlyList<CollageItem> Items);
Vertical stitching is particularly useful for:
Screenshot collections
Product descriptions
Tutorials
Chat conversations
Step-by-step guides
Mobile-friendly social media content
Join the conversation! Your thoughts help the community grow.