From Theory to a Working Tool

Now you should be familiar with the mental model: which format to choose, what @jsquash is, and how the decode-encode pipeline works at the API level. This lesson is where that knowledge becomes a real, runnable SvelteKit application.

By the end you will have built a drag-and-drop image compressor: a DropZone that accepts files, a typed optimizer module that decodes JPEG, PNG, or WebP and encodes to WebP entirely in the browser using @jsquash, a quality slider that re-encodes the cached pixels without redoing the decode, and a pointer-driven before/after comparison with a live file-size readout.

The article follows the natural order of real development: scaffold the project, install dependencies, configure the build, design the state, then build each piece and wire it together.


Step 1: Scaffold the Project

Start with a fresh SvelteKit project using the official CLI. When prompted, choose TypeScript, ESLint, and Prettier.

npx sv create image-optimizer

Choose these options when asked:

Which template would you like? SvelteKit minimal
Add type checking with TypeScript? Yes, using TypeScript syntax
Add ESLint for code linting? Yes
Add Prettier for code formatting? Yes

Confirm the dev server runs before going further:

cd image-optimizer
pnpm install
pnpm run dev

Step 2: Install @jsquash

This project uses three @jsquash codec packages: JPEG, PNG, and WebP. JPEG and PNG are decoders for likely input formats; WebP is both an encoder (for the optimised output) and a decoder (so users can also drop WebP files in). All three ship their own TypeScript declarations.

pnpm add @jsquash/jpeg @jsquash/png @jsquash/webp

Configure Vite

Several @jsquash packages have a known conflict with Vite’s dependency optimiser. Add an explicit exclusion now to avoid build-time surprises later:

// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'

export default defineConfig({
	plugins: [sveltekit()],
	optimizeDeps: {
		exclude: ['@jsquash/jpeg', '@jsquash/png', '@jsquash/webp']
	}
})

Step 3: Plan the Architecture

Before writing any component code, it helps to see how the pieces of the finished tool connect.

Loading diagram...

Each piece has a single responsibility. DropZone and the quality slider feed the logic module; the logic module decodes/encodes and exposes reactive state; the page component wires data flow; ComparisonSlider handles the visual presentation.

This separation is deliberate and load-bearing. In Lesson 5, the WASM pipeline moves into a Web Worker. When that happens, only the logic module changes. The DropZone and the comparison slider are completely untouched.


Step 4: Create the File Structure

Create the following files. The page route will live at /optimizer.

src/
  app.css                       ← global theme tokens used by every component
  lib/
    optimizer.svelte.ts         ← reactive state + @jsquash pipeline
    components/
      DropZone.svelte           ← file input with drag-and-drop
      ComparisonSlider.svelte   ← before/after slider, pointer-driven
  routes/
    +layout.svelte              ← imports app.css once for the whole app
    optimizer/
      +page.svelte              ← wires everything together

Step 5: Theme Tokens

Components will reference design tokens such as var(--accent-primary-base) and var(--surface-1). If those variables are not declared anywhere, every background: var(--accent-primary-base) rule resolves to empty, and elements render invisibly. The comparison slider’s divider and round drag handle are the most obvious casualties - you end up with a working slider you cannot see.

Define the tokens once at :root:

/* src/app.css */
:root {
	--bg: oklch(98% 0.005 260);
	--text: oklch(20% 0.02 260);
	--surface-1: oklch(96% 0.01 260);
	--border-default: oklch(85% 0.015 260);
	--accent-primary-base: oklch(62% 0.18 30);
	--accent-danger-base: oklch(58% 0.22 25);
	--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
	--radius-md: 6px;

	color-scheme: light dark;
}

@media (prefers-color-scheme: dark) {
	:root {
		--bg: oklch(16% 0.02 260);
		--text: oklch(96% 0.005 260);
		--surface-1: oklch(22% 0.02 260);
		--border-default: oklch(34% 0.02 260);
	}
}

html,
body {
	margin: 0;
	padding: 0;
	background: var(--bg);
	color: var(--text);
	font-family:
		system-ui,
		-apple-system,
		'Segoe UI',
		Roboto,
		sans-serif;
	min-height: 100%;
}

*,
*::before,
*::after {
	box-sizing: border-box;
}

Import it once from the root layout so every route gets it:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import '../app.css'

	let { children } = $props()
</script>

{@render children()}
Always provide a fallback inside var()

Even with app.css in place, every component-scoped rule that uses a token should still pass a fallback: background: var(--accent-primary-base, #ff5722). If the stylesheet ever fails to load, you keep a visible UI instead of a blank page.


Step 6: The Optimizer Logic Module

This is the most important file in the project. It owns the reactive state that describes the optimizer’s lifecycle and contains all decode/encode logic. It lives outside any component so the same state can be read from multiple places.

The optimizer is always in one of four modes:

Loading diagram...

Three design choices in this module pay back later:

  1. Per-format dispatch: a small detectFormat() resolves the file’s MIME or extension to 'jpeg' | 'png' | 'webp' and routes to the matching @jsquash decoder.
  2. preserveOrientation: true on JPEG decode: applies the EXIF Orientation tag while decoding, so a phone photo’s optimised output lines up with the auto-rotated original <img> preview.
  3. A cached ImageData: changing the quality slider re-encodes from the cached pixels (~tens of ms) instead of re-decoding (which on a large JPEG is the slow step).

Create src/lib/optimizer.svelte.ts:

// src/lib/optimizer.svelte.ts

import { decode as decodeJpeg } from '@jsquash/jpeg'
import { decode as decodePng } from '@jsquash/png'
import { decode as decodeWebp, encode as encodeWebp } from '@jsquash/webp'

// A union type makes the status exhaustively checkable in templates.
type OptimizerStatus = 'idle' | 'processing' | 'done' | 'error'

// Resolve a file to one of the formats @jsquash can decode. We rely on the
// browser-reported MIME type first, then fall back to the file extension  - 
// some platforms (notably iOS) drop or mis-report `file.type` when the file
// is dragged in from another app.
type SupportedFormat = 'jpeg' | 'png' | 'webp'

function detectFormat(file: File): SupportedFormat | null {
	const type = (file.type || '').toLowerCase()
	if (type === 'image/jpeg' || type === 'image/jpg') return 'jpeg'
	if (type === 'image/png') return 'png'
	if (type === 'image/webp') return 'webp'

	const name = file.name.toLowerCase()
	if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'jpeg'
	if (name.endsWith('.png')) return 'png'
	if (name.endsWith('.webp')) return 'webp'

	return null
}

async function decodeWithJsquash(format: SupportedFormat, buffer: ArrayBuffer): Promise<ImageData> {
	switch (format) {
		case 'jpeg':
			// preserveOrientation: true tells @jsquash/jpeg to apply the EXIF
			// Orientation tag while decoding. Without it, phone photos decode
			// in their raw sensor orientation while the browser auto-rotates
			// the original <img>, so the before/after look disagree.
			return decodeJpeg(buffer, { preserveOrientation: true })
		case 'png':
			return decodePng(buffer)
		case 'webp':
			return decodeWebp(buffer)
	}
}

// Default WebP quality used when the page first loads. @jsquash/webp's own
// internal default is 75; we use 80 as a slightly safer photography baseline.
export const DEFAULT_QUALITY = 80

export function createOptimizer() {
	let status = $state<OptimizerStatus>('idle')
	let originalUrl = $state<string | null>(null)
	let originalSize = $state<number>(0)
	let optimizedUrl = $state<string | null>(null)
	let optimizedSize = $state<number>(0)
	let errorMessage = $state<string | null>(null)
	let quality = $state<number>(DEFAULT_QUALITY)
	// Quality the *currently displayed* optimized blob was encoded at. Lags
	// behind `quality` until the encode completes; lets the UI show what
	// the user is actually looking at versus what they've selected.
	let appliedQuality = $state<number>(DEFAULT_QUALITY)

	// Cache the decoded pixels of the most recent file so changing the
	// quality slider only triggers a re-encode (fast) instead of a full
	// decode + encode round-trip. ImageData is not reactive itself; we
	// keep it in a plain variable.
	let lastImageData: ImageData | null = null

	// Monotonic token to invalidate stale async results. If a re-encode is
	// in flight when the user moves the slider again, the older promise
	// resolving must not overwrite the newer one's URL/size.
	let jobToken = 0

	function clampQuality(value: number): number {
		if (!Number.isFinite(value)) return DEFAULT_QUALITY
		return Math.min(100, Math.max(1, Math.round(value)))
	}

	async function encodeAndPublish(imageData: ImageData, token: number): Promise<Blob | null> {
		// Snapshot the current quality before awaiting so we can also
		// publish it to `appliedQuality` after the encode completes.
		const usedQuality = quality
		try {
			const webpBuffer: ArrayBuffer = await encodeWebp(imageData, { quality: usedQuality })
			if (token !== jobToken) return null // a newer job superseded us

			if (optimizedUrl) URL.revokeObjectURL(optimizedUrl)
			const blob = new Blob([webpBuffer], { type: 'image/webp' })
			optimizedUrl = URL.createObjectURL(blob)
			optimizedSize = blob.size
			appliedQuality = usedQuality
			status = 'done'
			return blob
		} catch (err) {
			if (token !== jobToken) return null
			console.error('[optimizer] encode failed:', err)
			errorMessage = err instanceof Error ? err.message : 'Unknown encoding error.'
			status = 'error'
			return null
		}
	}

	async function squash(file: File): Promise<Blob | null> {
		// Revoke previous object URLs before creating new ones.
		// URL.createObjectURL() allocates memory the browser will not release
		// until you explicitly call revokeObjectURL().
		if (originalUrl) URL.revokeObjectURL(originalUrl)
		if (optimizedUrl) URL.revokeObjectURL(optimizedUrl)
		originalUrl = null
		optimizedUrl = null
		errorMessage = null
		lastImageData = null
		const token = ++jobToken

		status = 'processing'
		originalSize = file.size
		originalUrl = URL.createObjectURL(file)

		try {
			const format = detectFormat(file)
			if (!format) {
				throw new Error(
					`Unsupported file type${file.type ? `: ${file.type}` : ''}. Use JPEG, PNG, or WebP.`
				)
			}

			const buffer: ArrayBuffer = await file.arrayBuffer()
			const imageData: ImageData = await decodeWithJsquash(format, buffer)
			if (token !== jobToken) return null // user dropped a newer file
			lastImageData = imageData

			return await encodeAndPublish(imageData, token)
		} catch (err) {
			if (token !== jobToken) return null
			console.error('[optimizer] failed to squash file:', err)
			errorMessage = err instanceof Error ? err.message : 'Unknown compression error.'
			status = 'error'
			return null
		}
	}

	async function setQuality(value: number): Promise<Blob | null> {
		const next = clampQuality(value)
		if (next === quality) return null
		quality = next

		// Without a decoded image yet, just remember the chosen quality so
		// the next squash() picks it up.
		if (!lastImageData) return null

		const token = ++jobToken
		errorMessage = null
		status = 'processing'
		return encodeAndPublish(lastImageData, token)
	}

	// Return getter properties, not bare values.
	// If you write `return { status, optimizedUrl }`, JavaScript copies the
	// current primitive values at the moment the factory runs and they never
	// update. Getters read the live $state variable on every access.
	return {
		get status() {
			return status
		},
		get originalUrl() {
			return originalUrl
		},
		get originalSize() {
			return originalSize
		},
		get optimizedUrl() {
			return optimizedUrl
		},
		get optimizedSize() {
			return optimizedSize
		},
		get errorMessage() {
			return errorMessage
		},
		get quality() {
			return quality
		},
		get appliedQuality() {
			return appliedQuality
		},
		get canReencode() {
			return lastImageData !== null
		},
		squash,
		setQuality
	}
}
Getters, not bare values - the most common mistake

Writing return { status, originalUrl, squash } from a factory function copies the primitive values at the moment of the call. The consumer reads { status: 'idle' } and that object never updates, regardless of how many times squash() changes the internal $state. Always use getter properties so the consumer reads the live variable on each access.

Why .svelte.ts and not .ts?

Files with the .svelte.ts extension are processed by the Svelte compiler, which means you can use $state, $derived, and $effect outside of any component. A plain .ts file does not go through the Svelte compiler and runes will cause a syntax error. Any module that uses runes must end in .svelte.ts (or .svelte.js).

Why a jobToken instead of just one in-flight promise?

If the user is dragging the quality slider and clicks again before the previous encode completes, two encodes run concurrently. Without a token, whichever resolves last overwrites optimizedUrl even if it’s stale. The token is incremented at the start of every job and checked after every await; a returning promise whose token no longer matches simply returns without touching state.


Step 7: Build the DropZone Component

The DropZone accepts files through two paths: the standard file picker (<input type="file">) and HTML drag-and-drop. Both paths resolve to the same callback.

Create src/lib/components/DropZone.svelte:

<script lang="ts">
	interface Props {
		onfile: (file: File) => void
	}

	let { onfile }: Props = $props()

	let isDragging = $state<boolean>(false)

	function handleDrop(event: DragEvent): void {
		event.preventDefault()
		isDragging = false

		const file = event.dataTransfer?.files[0]
		if (file) onfile(file)
	}

	function handleFileInput(event: Event): void {
		const input = event.currentTarget as HTMLInputElement
		const file = input.files?.[0]
		if (file) onfile(file)
	}
</script>

<div
	class="drop-zone"
	class:dragging={isDragging}
	role="button"
	tabindex="0"
	ondragover={(e: DragEvent) => {
		e.preventDefault()
		isDragging = true
	}}
	ondragleave={() => {
		isDragging = false
	}}
	ondrop={handleDrop}
>
	<label for="file-input" class="drop-zone__label">
		{#if isDragging}
			Release to compress
		{:else}
			Drop an image here, or <span class="drop-zone__browse">browse</span>
		{/if}
	</label>

	<input
		id="file-input"
		type="file"
		accept="image/jpeg,image/png,image/webp"
		class="sr-only"
		onchange={handleFileInput}
	/>
</div>

<style>
	.drop-zone {
		display: flex;
		align-items: center;
		justify-content: center;
		min-height: 200px;
		border: 2px dashed var(--border-default);
		border-radius: var(--radius-md, 6px);
		cursor: pointer;
		transition:
			border-color 150ms,
			background-color 150ms;
	}

	.drop-zone.dragging {
		border-color: var(--accent-primary-base);
		background-color: color-mix(in oklch, var(--accent-primary-base) 8%, transparent);
	}

	.drop-zone__label {
		cursor: pointer;
		/*
		 * Do NOT set pointer-events: none here. The label is what routes
		 * a click (via for="file-input") to the hidden <input>; without
		 * pointer-events the click falls through to the parent div and
		 * the file picker never opens.
		 */
	}

	.drop-zone__browse {
		text-decoration: underline;
	}

	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		padding: 0;
		margin: -1px;
		overflow: hidden;
		clip: rect(0, 0, 0, 0);
		white-space: nowrap;
		border: 0;
	}
</style>
Why does dragover need preventDefault?

Without event.preventDefault() on ondragover, the browser treats the element as a non-drop-target and reverts the cursor to the “no drop” icon. The drop event will never fire. You must opt in to being a drop target by preventing the default behaviour on every dragover event.


Step 8: Build the ComparisonSlider Component

The slider renders both images stacked, with the optimised one clipped by a CSS clip-path driven by a $state variable. The historical approach is to put a transparent <input type="range"> over the images and let the browser drive the position. That technique fails on this kind of UI: the range input’s thumb has effectively zero geometry once you stretch it, so the very first click is interpreted as “thumb outside the track” and the value snaps to min (0). The divider jumps to the left edge and the user can’t grab it from where they clicked.

Instead, drive the slider yourself with the Pointer Events API. On pointerdown, capture the pointer and set the position from the click X immediately. On pointermove, update only while capture is active. The result: you can click anywhere and start dragging from that exact spot.

Create src/lib/components/ComparisonSlider.svelte:

<script lang="ts">
	interface Props {
		before: string
		after: string
		originalSize: number
		optimizedSize: number
		appliedQuality?: number
	}

	let { before, after, originalSize, optimizedSize, appliedQuality }: Props = $props()

	let sliderPos = $state<number>(50)
	let imagesEl = $state<HTMLDivElement | null>(null)

	const savingsPct = $derived<number>(
		originalSize > 0 ? Math.round((1 - optimizedSize / originalSize) * 100) : 0
	)

	const originalKB = $derived<string>((originalSize / 1024).toFixed(1))
	const optimizedKB = $derived<string>((optimizedSize / 1024).toFixed(1))

	// NOTE: object URL lifetime is owned by the optimizer that created them.
	// Revoking here would race with re-renders  -  dropping a second image
	// would invalidate the URLs the new render is about to use.

	function updateFromPointer(clientX: number): void {
		if (!imagesEl) return
		const rect = imagesEl.getBoundingClientRect()
		if (rect.width === 0) return
		const pct = ((clientX - rect.left) / rect.width) * 100
		sliderPos = Math.min(100, Math.max(0, pct))
	}

	function handlePointerDown(event: PointerEvent): void {
		if (event.button !== 0 && event.pointerType === 'mouse') return
		event.preventDefault()
		;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
		updateFromPointer(event.clientX)
	}

	function handlePointerMove(event: PointerEvent): void {
		const target = event.currentTarget as HTMLElement
		if (!target.hasPointerCapture(event.pointerId)) return
		updateFromPointer(event.clientX)
	}

	function handlePointerUp(event: PointerEvent): void {
		const target = event.currentTarget as HTMLElement
		if (target.hasPointerCapture(event.pointerId)) {
			target.releasePointerCapture(event.pointerId)
		}
	}

	function handleKeyDown(event: KeyboardEvent): void {
		const step = event.shiftKey ? 10 : 1
		if (event.key === 'ArrowLeft') {
			sliderPos = Math.max(0, sliderPos - step)
			event.preventDefault()
		} else if (event.key === 'ArrowRight') {
			sliderPos = Math.min(100, sliderPos + step)
			event.preventDefault()
		} else if (event.key === 'Home') {
			sliderPos = 0
			event.preventDefault()
		} else if (event.key === 'End') {
			sliderPos = 100
			event.preventDefault()
		}
	}
</script>

<div class="comparison">
	<div
		bind:this={imagesEl}
		class="comparison__images"
		role="slider"
		tabindex="0"
		aria-label="Compare original and optimised image"
		aria-valuemin="0"
		aria-valuemax="100"
		aria-valuenow={Math.round(sliderPos)}
		onpointerdown={handlePointerDown}
		onpointermove={handlePointerMove}
		onpointerup={handlePointerUp}
		onpointercancel={handlePointerUp}
		onkeydown={handleKeyDown}
	>
		<!-- svelte-ignore a11y_img_redundant_alt -->
		<img
			src={before}
			alt="Original image"
			class="comparison__img comparison__img--base"
			draggable="false"
		/>

		<!--
      Clip the OPTIMISED overlay from the LEFT so the right-hand portion of
      the container shows the after image. With sliderPos at 50%, the left
      half exposes the base (Original) and the right half shows the overlay
      (Optimised), matching the labels positioned below.
    -->
		<!-- svelte-ignore a11y_img_redundant_alt -->
		<img
			src={after}
			alt="Optimised image"
			class="comparison__img comparison__img--overlay"
			style:clip-path="inset(0 0 0 {sliderPos}%)"
			draggable="false"
		/>

		<div class="comparison__divider" style:left="{sliderPos}%">
			<span class="comparison__handle" aria-hidden="true"></span>
		</div>

		<span class="comparison__label comparison__label--before">Original</span>
		<span class="comparison__label comparison__label--after">Optimised</span>
	</div>

	<div class="comparison__stats">
		<span class="comparison__stat">
			<span class="comparison__stat-label">Original</span>
			<strong>{originalKB} KB</strong>
		</span>
		<span class="comparison__stat comparison__stat--highlight">
			<span class="comparison__stat-label">Saved</span>
			<strong>{savingsPct}%</strong>
		</span>
		<span class="comparison__stat">
			<span class="comparison__stat-label">
				Optimised{appliedQuality !== undefined ? ` @ q=${appliedQuality}` : ''}
			</span>
			<strong>{optimizedKB} KB</strong>
		</span>
	</div>
</div>

<style>
	.comparison {
		position: relative;
		display: flex;
		flex-direction: column;
		gap: 1rem;
		user-select: none;
	}

	.comparison__images {
		position: relative;
		overflow: hidden;
		aspect-ratio: 16 / 9;
		background: var(--surface-1, #111);
		cursor: col-resize;
		touch-action: none;
	}

	.comparison__images:focus-visible {
		outline: 2px solid var(--accent-primary-base, #ff5722);
		outline-offset: 2px;
	}

	.comparison__img {
		position: absolute;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: contain;
		user-select: none;
		-webkit-user-drag: none;
		pointer-events: none;
	}

	.comparison__divider {
		position: absolute;
		top: 0;
		bottom: 0;
		width: 2px;
		background: var(--accent-primary-base, #ff5722);
		transform: translateX(-50%);
		pointer-events: none;
	}

	.comparison__handle {
		position: absolute;
		top: 50%;
		left: 50%;
		width: 28px;
		height: 28px;
		transform: translate(-50%, -50%);
		background: var(--accent-primary-base, #ff5722);
		border-radius: 50%;
		box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.85);
	}

	.comparison__label {
		position: absolute;
		top: 0.5rem;
		font-size: 0.75rem;
		font-family: var(--font-mono, ui-monospace, monospace);
		text-transform: uppercase;
		letter-spacing: 0.05em;
		color: var(--text, #fff);
		background: color-mix(in oklch, var(--bg, #000) 80%, transparent);
		padding: 0.2em 0.5em;
		pointer-events: none;
	}

	.comparison__label--before {
		left: 0.5rem;
	}
	.comparison__label--after {
		right: 0.5rem;
	}

	.comparison__stats {
		display: grid;
		grid-template-columns: 1fr auto 1fr;
		gap: 1rem;
		padding: 0.75rem 1rem;
		background: var(--surface-1, #1a1a1a);
		border: 1px solid var(--border-default, #333);
		color: var(--text, #fff);
	}

	.comparison__stat {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
	}

	.comparison__stat:last-child {
		text-align: right;
	}

	.comparison__stat--highlight {
		text-align: center;
		color: var(--accent-primary-base, #ff5722);
	}

	.comparison__stat-label {
		font-size: 0.7rem;
		font-family: var(--font-mono, ui-monospace, monospace);
		text-transform: uppercase;
		opacity: 0.6;
	}
</style>
Don't revoke object URLs from the slider

It’s tempting to add a $effect that revokes before/after when the component unmounts. Don’t. The optimizer module owns those URLs - it revokes the old one before creating a new one when the user drops another file. Revoking from the slider double-frees the URL and the next render renders a broken image.

Why touch-action: none on the slider area?

Without it, mobile browsers will start a horizontal pan or pinch-zoom gesture as soon as the user’s finger moves a few pixels - interrupting the comparison drag. touch-action: none tells the browser the element handles its own touch input.


Step 9: Wire It Together on the Page

With the logic module and components in place, the page component has very little to do. Its job is to connect the pieces, expose the quality slider, and render the appropriate UI for each lifecycle state.

Create src/routes/optimizer/+page.svelte:

<script lang="ts">
	import { createOptimizer } from '$lib/optimizer.svelte'
	import DropZone from '$lib/components/DropZone.svelte'
	import ComparisonSlider from '$lib/components/ComparisonSlider.svelte'

	const o = createOptimizer()

	// Mirror of o.quality used while the range input is being dragged so
	// the displayed % updates live without committing a re-encode on every
	// pixel of motion. We commit on `onchange` (release) by calling
	// o.setQuality, which also brings o.quality back into sync.
	let liveQuality = $state<number>(o.quality)
	$effect(() => {
		liveQuality = o.quality
	})
</script>

<svelte:head>
	<title>Image Optimizer</title>
</svelte:head>

<main class="optimizer">
	<header class="optimizer__header">
		<h1>Client-Side Image Optimizer</h1>
		<p>
			Drop a JPEG, PNG, or WebP. Your image is compressed on your device. Nothing leaves your
			browser until you choose to save the result.
		</p>
	</header>

	<DropZone
		onfile={(file: File) => {
			void o.squash(file)
		}}
	/>

	<div class="optimizer__quality">
		<label for="quality" class="optimizer__quality-label">
			<span>WebP quality</span>
			<output for="quality">{liveQuality}%</output>
		</label>
		<input
			id="quality"
			type="range"
			min="1"
			max="100"
			step="1"
			value={liveQuality}
			oninput={(e) => (liveQuality = Number((e.currentTarget as HTMLInputElement).value))}
			onchange={(e) => {
				void o.setQuality(Number((e.currentTarget as HTMLInputElement).value))
			}}
			class="optimizer__quality-range"
		/>
		<p class="optimizer__quality-hint">
			Lower quality = smaller file. Default 80; @jsquash/webp's own default is 75.
		</p>
	</div>

	{#if o.status === 'processing'}
		<div class="optimizer__status" role="status" aria-live="polite">
			<span class="optimizer__spinner" aria-hidden="true"></span>
			Compressing...
		</div>
	{/if}

	{#if o.status === 'error'}
		<div class="optimizer__error" role="alert">
			{o.errorMessage ?? 'Compression failed. Try a different file.'}
		</div>
	{/if}

	{#if o.status === 'done' && o.originalUrl && o.optimizedUrl}
		<ComparisonSlider
			before={o.originalUrl}
			after={o.optimizedUrl}
			originalSize={o.originalSize}
			optimizedSize={o.optimizedSize}
			appliedQuality={o.appliedQuality}
		/>

		<div class="optimizer__actions">
			<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
			<a href={o.optimizedUrl} download="optimized.webp" class="optimizer__download-btn">
				Download optimised image
			</a>
		</div>
	{/if}
</main>

<style>
	.optimizer {
		max-width: 800px;
		margin: 0 auto;
		padding: 2rem;
		display: flex;
		flex-direction: column;
		gap: 2rem;
	}

	.optimizer__header p {
		color: color-mix(in oklch, var(--text) 70%, transparent);
		max-width: 56ch;
	}

	.optimizer__status {
		display: flex;
		align-items: center;
		gap: 0.75rem;
		font-family: var(--font-mono);
		font-size: 0.9rem;
	}

	.optimizer__spinner {
		display: inline-block;
		width: 1em;
		height: 1em;
		border: 2px solid color-mix(in oklch, var(--accent-primary-base) 30%, transparent);
		border-top-color: var(--accent-primary-base);
		border-radius: 50%;
		animation: spin 600ms linear infinite;
	}

	@keyframes spin {
		to {
			transform: rotate(360deg);
		}
	}

	.optimizer__error {
		padding: 0.75rem 1rem;
		background: color-mix(in oklch, var(--accent-danger-base) 10%, transparent);
		border: 1px solid var(--accent-danger-base);
		color: var(--accent-danger-base);
		font-family: var(--font-mono);
		font-size: 0.9rem;
	}

	.optimizer__download-btn {
		display: inline-flex;
		align-items: center;
		gap: 0.5rem;
		padding: 0.75rem 1.5rem;
		background: var(--accent-primary-base);
		color: var(--bg);
		font-family: var(--font-mono);
		font-size: 0.9rem;
		text-transform: uppercase;
		letter-spacing: 0.05em;
		text-decoration: none;
	}

	.optimizer__quality {
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
		padding: 0.75rem 1rem;
		background: var(--surface-1, #1a1a1a);
		border: 1px solid var(--border-default, #333);
		border-radius: var(--radius-md, 6px);
	}

	.optimizer__quality-label {
		display: flex;
		justify-content: space-between;
		align-items: baseline;
		font-family: var(--font-mono, ui-monospace, monospace);
		font-size: 0.85rem;
		text-transform: uppercase;
		letter-spacing: 0.05em;
	}

	.optimizer__quality-label output {
		color: var(--accent-primary-base, #ff5722);
		font-weight: 600;
	}

	.optimizer__quality-range {
		width: 100%;
		accent-color: var(--accent-primary-base, #ff5722);
	}

	.optimizer__quality-hint {
		margin: 0;
		font-size: 0.75rem;
		opacity: 0.7;
	}
</style>
Why oninput and onchange both?

oninput fires continuously while the user drags; onchange fires once on release. We use the first to keep the displayed % in sync with the thumb position, and the second to actually commit the new quality. Without that split, the WASM encoder runs dozens of times during a single drag - wasted work and visible jank.

The {#if} block for the 'done' state additionally checks o.originalUrl && o.optimizedUrl. Those fields are typed string | null, so the null guard narrows the types cleanly without any type assertions.


Step 10: Run It

Start the dev server and navigate to http://localhost:5173/optimizer:

pnpm run dev

Drop a JPEG. The spinner appears briefly while @jsquash decodes and re-encodes the file, then the comparison slider loads with the result. Move the quality slider; the panel updates with the new size and shows the actual quality the displayed image was encoded at (e.g. Optimised @ q=15).

To confirm that nothing left the browser, open Chrome DevTools, go to the Network tab, and clear it before dropping a file. During compression you will see no network requests for your image. The WASM binary download may appear on first use; that’s a one-time code fetch, not your data.


Common Pitfalls

Phone photos look rotated after compression

By default, @jsquash/jpeg’s decode() returns the raw sensor pixels and ignores the EXIF Orientation tag. The browser, however, auto-rotates the original <img> preview based on that same tag. Result: original looks correct, optimised looks rotated.

// Avoid: returns raw sensor orientation
const imageData = await decodeJpeg(buffer)
// Correct: applies the EXIF Orientation tag during decode
const imageData = await decodeJpeg(buffer, { preserveOrientation: true })

The comparison slider jumps to the left when clicked

This happens when you put a transparent native <input type="range"> on top of the images. Don’t. Drive the slider with pointerdown + setPointerCapture + a clientX → percentage calculation against the container’s bounding rect. See Step 8.

The slider’s divider and handle are invisible

Components reference theme tokens like var(--accent-primary-base), but if you never declare those tokens at :root, they resolve to nothing - no background colour, transparent line, invisible handle. Step 5’s app.css fixes this. As a defensive measure, also pass a fallback to every var() so the UI degrades gracefully: background: var(--accent-primary-base, #ff5722).

lang="ts" missing on the script block

// Avoid: runes are not available in plain .ts files
// src/lib/optimizer.ts  -  this will throw a syntax error on $state
let status = $state('idle')
// Correct: the .svelte.ts extension enables rune processing
// src/lib/optimizer.svelte.ts
let status = $state<OptimizerStatus>('idle')

Forgetting to type $state with nullable values

// Avoid: implicit null type
let optimizedUrl = $state(null)
// Preferred: explicit generic makes the intent clear
let optimizedUrl = $state<string | null>(null)

Performance Note: This Runs on the Main Thread

The optimizer as built blocks the main JavaScript thread during WASM encoding. For a small photo (200–400 KB), that takes 50–150 ms and is imperceptible. For a large file (8 MB DSLR JPEG), it can take two to four seconds; the spinner stops animating and clicks do not register.

The quality slider’s “fast re-encode from cached ImageData” trick already buys you a lot, you only pay the slow decode once per file, but the encode itself still blocks.

Lesson 5 fixes this with a Web Worker

Moving the WASM pipeline into a Web Worker eliminates main-thread blocking entirely. The transition requires changing only optimizer.svelte.ts; the DropZone and ComparisonSlider components are completely unaffected. That’s why the architecture was designed with this separation from the start.


What Comes Next

You now have a working, type-safe, memory-managed image optimizer built in SvelteKit, with EXIF-aware decoding, a quality slider that re-encodes from cached pixels, and a pointer-driven comparison slider. Lesson 4 adds a metadata extraction step using exifr - running before compression, extracting GPS coordinates and camera data from the EXIF before @jsquash strips it, and preparing that data for your database. Lesson 5 moves the entire WASM pipeline into a Web Worker so large files no longer block the UI.


Key Takeaways

  • Use @jsquash/jpeg, @jsquash/png, and @jsquash/webp together so the optimizer accepts whatever the user drops, and add all three to optimizeDeps.exclude in vite.config.ts.
  • Pass { preserveOrientation: true } to @jsquash/jpeg’s decode() so phone photos line up with their auto-rotated browser preview.
  • Declare every CSS variable the components reference at :root in app.css, and still pass a fallback to every var() call.
  • Logic modules that use Svelte 5 runes must have the .svelte.ts extension; a plain .ts file is not processed by the Svelte compiler.
  • Always return getter properties from a factory function, never bare values. This is the most common source of “why does nothing update” bugs in .svelte.ts modules.
  • Cache the decoded ImageData on the optimizer so the quality slider re-encodes (~tens of ms) instead of re-decoding (~hundreds).
  • Drive the comparison slider with the Pointer Events API. A native <input type="range"> overlay snaps to min on click and is unreachable from where the user actually clicked.
  • Use a monotonic jobToken so a stale async result can never overwrite a fresher one.

Further Reading

See Also