diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts
new file mode 100644
index 00000000..6cd01113
--- /dev/null
+++ b/apps/web/src/hooks/use-mobile.ts
@@ -0,0 +1,22 @@
+import { useState, useEffect } from "react";
+
+const MOBILE_BREAKPOINT = 768;
+
+/**
+ * Returns true when the viewport width is below the mobile breakpoint (768px).
+ */
+export function useMobile(): boolean {
+ const [isMobile, setIsMobile] = useState(() =>
+ typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : false
+ );
+
+ useEffect(() => {
+ const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
+ const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
+ mq.addEventListener("change", handler);
+ setIsMobile(mq.matches);
+ return () => mq.removeEventListener("change", handler);
+ }, []);
+
+ return isMobile;
+}
diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx
index a783afe6..8e559ace 100644
--- a/apps/web/src/pages/tool-page.tsx
+++ b/apps/web/src/pages/tool-page.tsx
@@ -1,10 +1,11 @@
import { useParams } from "react-router-dom";
-import { useMemo, useCallback } from "react";
+import { useMemo, useCallback, useState } from "react";
import { TOOLS } from "@stirling-image/shared";
import { AppLayout } from "@/components/layout/app-layout";
import { Dropzone } from "@/components/common/dropzone";
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { useFileStore } from "@/stores/file-store";
+import { useMobile } from "@/hooks/use-mobile";
import { ResizeSettings } from "@/components/tools/resize-settings";
import { CropSettings } from "@/components/tools/crop-settings";
import { RotateSettings } from "@/components/tools/rotate-settings";
@@ -46,6 +47,7 @@ import { BlurFacesSettings } from "@/components/tools/blur-faces-settings";
import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
import * as icons from "lucide-react";
+import { cn } from "@/lib/utils";
const COLOR_TOOL_IDS = new Set([
"brightness-contrast",
@@ -111,6 +113,8 @@ export function ToolPage() {
const { toolId } = useParams<{ toolId: string }>();
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
const { files, setFiles, reset, processedUrl, originalBlobUrl, originalSize, processedSize } = useFileStore();
+ const isMobile = useMobile();
+ const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
const handleFiles = useCallback(
(newFiles: File[]) => {
@@ -141,6 +145,84 @@ export function ToolPage() {
const hasFile = files.length > 0;
const isNoDropzone = NO_DROPZONE_TOOLS.has(tool.id);
+ // Mobile layout: settings above dropzone (stacked)
+ if (isMobile) {
+ return (
+
+
+ {/* Tool header */}
+
+
+
+
+
+ {tool.name}
+
+
+
+
+ {/* Collapsible settings */}
+ {mobileSettingsOpen && (
+
+ {/* File info */}
+ {!isNoDropzone && hasFile && (
+
+ {files.map((f, i) => (
+
+ {f.name}
+
+ {(f.size / 1024).toFixed(0)} KB
+
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {/* Dropzone / Preview */}
+
+ {isNoDropzone ? (
+
+
Configure settings and generate.
+
+ ) : processedUrl && originalBlobUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+ }
+
+ // Desktop layout: side-by-side
return (