- Choosing the Right Image for the Job Vector vs raster, AVIF vs WebP, lossy vs lossless, and the format decision tree every developer needs before touching a compression tool.
- Squoosh as a Library @jsquash ports Squoosh's professional codecs to your browser via WASM - no server, no ImageMagick, no build step needed.
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.
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.cssin 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:
Three design choices in this module pay back later:
- Per-format dispatch: a small
detectFormat()resolves the file’s MIME or extension to'jpeg' | 'png' | 'webp'and routes to the matching@jsquashdecoder. preserveOrientation: trueon 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.- 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 mistakeWriting
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 timessquash()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.tsextension are processed by the Svelte compiler, which means you can use$state,$derived, and$effectoutside of any component. A plain.tsfile 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
optimizedUrleven if it’s stale. The token is incremented at the start of every job and checked after everyawait; 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()onondragover, the browser treats the element as a non-drop-target and reverts the cursor to the “no drop” icon. Thedropevent will never fire. You must opt in to being a drop target by preventing the default behaviour on everydragoverevent.
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 sliderIt’s tempting to add a
$effectthat revokesbefore/afterwhen 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: nonetells 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?
oninputfires continuously while the user drags;onchangefires 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 WorkerMoving 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/webptogether so the optimizer accepts whatever the user drops, and add all three tooptimizeDeps.excludeinvite.config.ts. - Pass
{ preserveOrientation: true }to@jsquash/jpeg’sdecode()so phone photos line up with their auto-rotated browser preview. - Declare every CSS variable the components reference at
:rootinapp.css, and still pass a fallback to everyvar()call. - Logic modules that use Svelte 5 runes must have the
.svelte.tsextension; a plain.tsfile 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.tsmodules. - Cache the decoded
ImageDataon 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 tominon click and is unreachable from where the user actually clicked. - Use a monotonic
jobTokenso a stale async result can never overwrite a fresher one.
Further Reading
- SvelteKit documentation: project structure
- Svelte 5: $state rune
- Svelte 5: TypeScript
- @jsquash on GitHub
- URL.createObjectURL() on MDN
- Pointer Events on MDN
- HTML Drag and Drop API on MDN
- EXIF Orientation explained (impulseadventure.com)
See Also
- Lesson 3: @jsquash and WebAssembly - the codec library this optimizer wraps.
- Lesson 5: Metadata - Save the Brain Before You Squash the Body - the next lesson, covering why
exifrshould run before encoding and how orientation/GPS are handled. - Lesson 6: Keeping Your UI at 60fps - the next iteration of this optimizer: the same
createOptimizerAPI, but encoding moved off the main thread.