2026

28. Mai 2026

Eine Web-App, die sich für einen Desktop hält

Was sich in einer React-App ändert, wenn man sie nicht mehr wie eine Webseite, sondern wie eine Desktop-Umgebung behandelt: Fenster, ein Dock und ein geteiltes 3D-Canvas.

S
Sascha Becker
Author

18 Min. Lesezeit

Eine Web-App, die sich für einen Desktop hält

Eine Web-App, die sich für einen Desktop hält

Die meisten Web-Apps im Jahr 2026 haben dieselbe Form. Ein klebriger Header oben mit Marke und Navigation. Eine permanente Sidebar links. Ein scrollender Inhaltsbereich in der Mitte. Ein Floating Action Button irgendwo unten rechts. Sie unterscheiden sich in Palette und Produkt. In der Geometrie unterscheiden sie sich nicht wirklich.

Mintables, der kleine parametrische 3D-Teile-Generator, an dem ich baue, hatte diese Form auch. Ein Monorepo browserbasierter Generatoren (Tubes, Adapters, Dividers, Leg Caps): du misst ein Teil aus, gibst die Zahlen ein, beobachtest die Live-Vorschau und lädst eine STL-Datei herunter. Die erste Version war eine ordentliche Material-App. Sidebar mit Controls, große Vorschau, Breadcrumb-Header, eine Startseite mit Hero-Text und Feature-Kacheln. Alles funktionierte. Nichts fühlte sich nach irgendetwas Bestimmtem an.

Dann habe ich einen anderen Instinkt ausprobiert: die App wie einen Desktop behandeln, nicht wie eine Webseite.

Der Mintables-Desktop. Ein fotografisches Berg-Wallpaper füllt den Viewport. Ein schwebendes Dock unten enthält vier App-Kacheln. Eine Spalte mit File-Style-Icons rechts verlinkt auf README, Lizenz, GitHub und die Sponsor-Seite.
Die Startseite ist weg. Was bleibt: ein Wallpaper, eine Menüleiste, ein Dock, ein paar Datei-Icons rechts.

Die Startseite ist verschwunden. Der Marketing-Text ist verschwunden. Der Hero ist verschwunden. Um einen Generator zu benutzen, öffnest du ihn wie eine App: er erscheint als verschiebbares, größenveränderbares Fenster über dem Wallpaper. Um einen früheren Export wiederzufinden, öffnest du den Downloads-Ordner. Um zwischen Apps zu wechseln, drückst du Cmd-K und tippst den Namen. Die Metapher verschiebt sich von "eine Website durchblättern" zu "einen Desktop benutzen".

Dieser Post ist die Verkabelung, die diese Umstellung brauchte. Manche der Lektionen sind ästhetisch. Die meisten sind technisch. Es gibt in jedem UI-Projekt einen Moment, in dem du gewohnte Web-Pattern gegen etwas eintauschst, das für das Framework weniger ergonomisch ist, und du musst herausfinden, wie es darunter zusammengehalten wird. Diese Verkabelung möchte ich zeigen.

Fenster sind erstklassig. URLs kommen hinten an.

Die größte mentale Verschiebung war die Entscheidung, wo die Identität eines Fensters lebt. Im Web ist die URL maßgeblich. /generators/tubes IST die Tubes-Ansicht. Routing-Systeme behandeln das so. URL öffnen, Ansicht rendern. Ansicht schließen, URL stehen lassen.

Ein Desktop funktioniert so nicht. Fenster haben einen eigenen Lebenszyklus. Du kannst drei gleichzeitig offen haben, auf drei verschiedenen Routen. Du kannst eines minimieren, und die URL folgt nicht. Du kannst ein Hintergrundfenster fokussieren, indem du es anklickst, und die URL sollte sich an dem orientieren, was obenauf liegt. Routen sind Beobachter, keine Eigentümer.

In Mintables lebt der Window-Manager deshalb im React-State, nicht in der URL.

ts
export type WindowPayload =
| { kind: "generator"; generatorId: string }
| { kind: "folder"; folderId: FolderId };
export interface OpenWindow {
/** Stable id derived from payload. */
id: string;
payload: WindowPayload;
state: "normal" | "minimized" | "maximized";
x: number;
y: number;
w: number;
h: number;
z: number;
}
export interface WindowManagerState {
windows: OpenWindow[];
focusedId: string | null;
nextZ: number;
}

Ein Reducer übernimmt OPEN, CLOSE, FOCUS, MINIMIZE, RESTORE, MAXIMIZE_TOGGLE, MOVE, RESIZE und SET_BOUNDS. Routen werden zu dünnen Shims. Die Route /generators/tubes rendert null und dispatcht beim Mounten openWindow({ kind: "generator", generatorId: "tubes" }). Die Route besitzt den Inhalt des Fensters nicht. Der Window-Manager tut das.

Die clevere Stelle ist die umgekehrte Richtung: der Pfad des fokussierten Fensters wird zurück in die URL gespiegelt, damit Deep-Linking und Zurück-Button weiterhin funktionieren.

ts
function useFocusUrlSync(focusedWindow: OpenWindow | null) {
const router = useRouter();
const pathname = usePathname();
const pathnameRef = useRef(pathname);
pathnameRef.current = pathname;
const target = focusedWindow
? pathForPayload(focusedWindow.payload)
: "/";
useEffect(() => {
if (target !== pathnameRef.current) router.replace(target);
}, [target, router]);
}

Dieser pathnameRef-Trick ist eines dieser Stolpersteine, die man nur findet, wenn man hineinläuft. Wenn der Nutzer von /generators/tubes zu /folders/downloads navigiert, aktualisiert sich der Pathname sofort. Der Shim der neuen Route hat seinen openWindow-Effekt aber noch nicht dispatcht. Würde useFocusUrlSync auf den Pathname reagieren, sähe es den neuen Pathname zusammen mit dem immer noch alten fokussierten Fenster und würde sofort zurück auf die alte URL replacen. Das stiehlt dem gerade geöffneten Fenster den Fokus. Den Pathname über ein Ref zu lesen, lässt uns "haben wir den schon abgeglichen?" prüfen, ohne von ihm abzuhängen. Ich habe einen Nachmittag gebraucht, um herauszufinden, warum die Navigation zu Downloads immer wieder zurück zu Tubes sprang.

Eine zweite Entscheidung lohnt sich zu erwähnen: das Verhalten einer App-Instanz. Die Fenster-ID wird aus dem Payload abgeleitet, nicht pro Klick erzeugt:

ts
export function windowIdOf(payload: WindowPayload): string {
if (payload.kind === "generator") return `generator:${payload.generatorId}`;
return "folder";
}

Tubes zweimal zu öffnen erzeugt keine zwei Tubes-Fenster. Es fokussiert das eine, das bereits existiert. So funktionieren macOS-Apps. Beide Ordnerarten teilen sich absichtlich eine einzige ID: Downloads zu öffnen, während Presets auf dem Bildschirm liegt, lässt dasselbe Ordnerfenster zum neuen Ordner navigieren. Dieselbe Metapher wie Finder.

Drag ist eine Layout-Eigenschaft, kein State-Update

Jedes Generator-Fenster hat seine eigene Akzentfarbe, seinen eigenen Dock-Kachel-Verlauf, seine eigene SVG-Illustration. Die Oberkante des Fensters hat eine Akzent-Highlight-Linie, die beim Fokussieren aufleuchtet. Reine Verzierung, die visuelle Tonart, die signalisiert: "das hier wird wie ein echtes Ding behandelt". Aber die Verzierung ist nicht der schwere Teil. Der schwere Teil ist, den Drag eines Fensters gut anfühlen zu lassen.

Im Web ist der Instinkt, Bounds in den React-State zu legen und bei jedem Pointer-Move neu zu rendern. Das funktioniert für ein einzelnes Fenster ohne teure Kinder. Bei vier offenen Fenstern, jedes mit einer Live-3D-Vorschau, wird daraus eine Diashow. Reconciliation-Kosten holen einen schnell ein.

Die Lösung: React während des Drags nichts sagen. Den transform direkt ins DOM schreiben. React erfährt das Ergebnis erst auf pointerup.

ts
const handleTitleBarPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
const targetX = drag.startX + (e.clientX - drag.startClientX);
const targetY = drag.startY + (e.clientY - drag.startClientY);
const clamped = clamp(targetX, targetY, bounds.w, bounds.h);
drag.lastX = clamped.x;
drag.lastY = clamped.y;
const el = winRef.current;
if (el) {
el.style.transform =
`translate3d(${clamped.x}px, ${clamped.y}px, 0) ${phaseTransform[phase]}`;
}
invalidatePreview();
};
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (drag?.pointerId !== e.pointerId) return;
onBoundsCommit(drag.lastX, drag.lastY, bounds.w, bounds.h);
dragRef.current = null;
};

Der Reducer-Dispatch feuert genau einmal, am Ende der Geste. Die anderen achtzig Frames sind reiner GPU-Transform. Das Aufräumen passiert in einem useLayoutEffect, der den Inline-transform strippt, sobald die neuen Bounds in den State committet sind. So übernimmt der nächste Render ohne Flackern.

Der invalidatePreview()-Aufruf ist die Steuer dafür, dass wir ein einziges 3D-Canvas über alle Fenster teilen, was der nächste Abschnitt ist. Das Canvas läuft im Demand-Modus. Wenn der Drag React umgeht, weiß das Canvas nichts vom Neuzeichnen. Die Funktion dispatcht ein Custom Event, eine Bridge-Komponente im Canvas hört darauf und stupst R3Fs invalidate() an.

Für die Resize-Handles an jeder Kante und jeder Ecke gibt es ein ähnliches Muster. Acht Handles insgesamt, alle mit demselben Ansatz "während der Geste Transform schreiben, beim Loslassen committen". Der Cursor fühlt sich nie hinterher an.

Ein WebGL-Kontext für die ganze App

Hier ist ein Problem, das ich nicht erwartet hatte: Browser begrenzen die Anzahl gleichzeitiger WebGL-Kontexte.

Die genaue Zahl variiert, aber Chromium zieht die Linie oft bei acht oder sechzehn. Wird sie überschritten, lässt der Browser den ältesten Kontext stillschweigend fallen. Jeder verlorene Kontext schwärzt sein <canvas> und friert dessen Inhalt ein. Keine Fehlermeldung. Die GPU hört einfach auf, mit dir zu reden.

Mintables ist multi-window. Jedes Fenster hat eine 3D-Vorschau. Der naive Ansatz ist ein <Canvas> pro Fenster. Der naive Ansatz scheitert: öffne und schließe Generatoren ein Dutzend Mal, und die Vorschau-Canvases beginnen einer nach dem anderen schwarz zu werden, weil alte Kontexte verdrängt werden. Die Lösung ist strukturell. Ein einziges Canvas für die ganze App, per Scissor in das reservierte div jedes Fensters gemalt.

R3F plus drei machen das mit der <View>-API praktikabel. Das PreviewPanel jedes Fensters rendert ein getracktes div mit einer <View> und seiner Szene. Ein einziges globales Canvas in der Page-Root mountet <View.Port />, das jede getrackte View zusammensetzt.

tsx
export function PreviewStage({ containerRef }: PreviewStageProps) {
return (
<Canvas
eventSource={containerRef as EventSourceRef}
eventPrefix="client"
dpr={[1, 2]}
frameloop="demand"
gl={{ antialias: true }}
style={{
position: "fixed",
inset: 0,
pointerEvents: "none",
zIndex: 1150,
}}
>
<InvalidateBridge />
<View.Port />
</Canvas>
);
}

Das Canvas liegt über den Fenstern, aber unter dem Dock. Es ist pointer-events: none, schluckt also keine Klicks. dreis <View> leitet Pointer-Events vom jeweiligen getrackten div in die richtige Szene. Die feste Positionierung bedeutet, das Canvas deckt den ganzen Viewport ab. Aber jedes <View> aktualisiert sein Scissor-Rechteck pro Frame über das getBoundingClientRect() seines getrackten divs, sodass die Szenen nur innerhalb ihrer Fenster malen.

frameloop="demand" macht das bezahlbar. Das Canvas ist im Ruhezustand, wenn sich nichts ändert. Sobald es ruht, kostet ein Fenster-Drag keine CPU-Arbeit. Die Ausnahme ist, wenn ein Fenster seine Form ändert (Drag, Resize, Minimize, Restore) und das Scissor folgen muss. Diese Momente rufen invalidatePreview() auf, damit das Canvas einen weiteren Frame malt und die View ihr Rechteck neu vermisst.

Ein schwebendes Fenster mit dem Titel 'Adapters' über dem Wallpaper. Der rechte Pane zeigt ein 3D-Render eines 90-Grad-Rohrbogens. Zwei durchscheinende Ghost-Tubes docken an jeder Buchse an und zeigen die Teile, die der Adapter verbinden wird.
Ein Canvas, per Scissor in ein Fenster gemalt. Einen zweiten Generator daneben zu öffnen erzeugt eine zweite getrackte View auf demselben Canvas.

Derselbe Trick skaliert auf so viele Fenster, wie der Nutzer zu öffnen bereit ist. Hinter den Kulissen gibt es weiterhin genau einen WebGL-Kontext.

Der Desktop verdient sich sein Mobiliar

Echte Desktops zeigen kein Mobiliar, das keinen Zweck hat. Ein leerer Dokumente-Ordner bleibt unsichtbar, bis du die erste Datei speicherst. Mintables folgt dieser Regel: Die Downloads- und Presets-Ordner existieren auf dem Desktop erst, nachdem du etwas produziert hast, das hineingehört.

Die Implementierung ist klein. Die Storage-Module dispatchen bei jedem Write ein Custom Event.

ts
const CHANGE_EVENT = "mintables:downloads-changed";
function emitChange(): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(CHANGE_EVENT));
}
export function recordDownload(/* ... */): DownloadEntry {
// write to localStorage ...
writeDownloads(next);
emitChange();
return entry;
}
export const DOWNLOADS_CHANGED_EVENT = CHANGE_EVENT;

Ein winziger Hook lauscht darauf, plus das native storage-Event für Updates zwischen Tabs.

ts
function useStorageFlag(
read: () => boolean,
changeEvent: string,
): boolean {
const [flag, setFlag] = useState(false);
useEffect(() => {
const sync = () => {
setFlag(read());
};
sync();
window.addEventListener(changeEvent, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(changeEvent, sync);
window.removeEventListener("storage", sync);
};
}, [read, changeEvent]);
return flag;
}

Der Desktop-Hub nutzt das, um seine bedingten Icons zu steuern. Kein Mutex mit dem Route-System, kein abgeleiteter State in einem Context, kein zustand-Store. Zwei Browser-Events und ein Hook. Das Icon erscheint in dem Moment, in dem du deinen ersten Download abschließt, ohne Reload.

Ein Finder-artiges Fenster mit dem Titel 'Downloads' zeigt ein Raster aus Datei-Kacheln. Jede Kachel ist farbcodiert nach dem Generator, der sie produziert hat: Tubes sind blaugrün, Adapters sind violett, Dividers sind bernsteinfarben, Leg Caps sind lindgrün. Eine Sidebar links listet Favoriten: Desktop, Downloads, Presets. Eine Statusleiste unten zeigt die Anzahl der Elemente.
Downloads-Ordner. Farbcodierte Dateien, Sidebar mit Favoriten, native anmutende Toolbar, Statusleiste.

Die Ordner-Fenster selbst benutzen eine geteilte FileExplorer-Komponente. Sie ist item-agnostisch: sie nimmt ein items-Array und ein actions-Array entgegen, kümmert sich um Selection (Einzelklick, Shift-Klick für Range, Cmd-Klick zum Togglen), Kontextmenüs, Tastatur-Shortcuts (Enter zum Öffnen, F2 zum Umbenennen, Entf zum Löschen, Cmd-A für Alles), Suche, Sortierung und Ansichtsumschaltung. Jedes Verhalten, nach dem ich in Finder greifen würde, ist da. Einen dritten Ordnertyp hinzuzufügen wäre ein neuer items-Provider.

Ein Finder-artiges Fenster mit dem Titel 'Presets' zeigt gespeicherte Konfigurationen über alle Generatoren hinweg, jede mit einem Namen wie 'Standard chair leg' oder 'Bin divider 65x35'. Die Dateien sind nach Quellgenerator farbcodiert.
Dieselbe FileExplorer-Komponente, andere Items. Presets listen Konfigurationen, die der Nutzer beim Einstellen gespeichert hat.

Ein nicht offensichtlicher Bonus: state-earned UI reduziert den Design-Aufwand für Empty States. Empty States sind in den meisten Apps eine ganze Fläche. Der Mintables-Desktop hat keine, weil die Fläche, deren Empty State man designen müsste, noch gar nicht existiert.

Spotlight ist die Tastatur-Eingangstür

Ein Dock funktioniert für die Maus. Für die Tastatur willst du etwas anderes. macOS löst das mit Spotlight: Cmd+Leertaste öffnet ein globales Suchfeld, du tippst ein paar Buchstaben, drückst Enter, das Ergebnis passiert. Zwei Sekunden, keine Mausbewegung. Mintables übernimmt die Idee. Cmd-K (anderswo Ctrl-K) öffnet eine frosted-glass-Palette über allem, die unscharf über Generatoren, Presets und Downloads in einer gerankten Liste sucht.

Eine Command-Palette aus mattiertem Glas schwebt zentriert über dem Desktop. Oben ein Suchfeld mit Platzhaltertext 'Search generators, presets, downloads…'. Darunter drei beschriftete Abschnitte: GENERATORS listet Tubes, Adapters, Dividers, Leg Caps mit ihren akzentfarbigen App-Kacheln. PRESETS listet Standard chair leg, Round table foot, Bin divider 65x35, 32mm broomstick socket. DOWNLOADS listet legcap-round-25mm-h20 (hervorgehoben), tube-round-100mm, adapter-90deg-elbow, divider-65x35-1mm. Eine Hinweisleiste unten zeigt die Tastenkürzel: Pfeiltasten zum Navigieren, Enter zum Öffnen, Escape zum Schließen.
Zustand mit leerer Suche. Drei Abschnitte, zwölf Einträge, alle per Pfeiltaste erreichbar.

Zwei Stellen der Implementierung sind sehenswert. Die erste ist der globale Keyboard-Handler. Er lebt in der Spotlight-Komponente selbst statt in einer separaten Shortcut-Registry, weil Spotlight das einzige Feature ist, das ihn braucht, und Lokalität schlägt frühe Zentralisierung.

ts
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const isCmdK =
(e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
if (!isCmdK) return;
if (!open) {
const t = e.target as HTMLElement | null;
const inField =
t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA");
if (inField) return;
e.preventDefault();
handleOpen();
return;
}
e.preventDefault();
handleClose();
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
};
}, [open, handleOpen, handleClose]);

Der "in field"-Ausstieg ist derselbe Trick, den der größere Window-Shortcut-Listener verwendet: ist das Event-Ziel ein <input> oder <textarea>, lass es in Ruhe, damit der Nutzer ein literales K tippen kann. Die Ausnahme ist, dass Cmd-K mit Fokus innerhalb von Spotlights eigenem Input die Palette trotzdem schließt. Ausschalten ist nützlicher, als ein K im Suchfeld zu landen.

Die zweite Stelle ist das Aktivierungsmuster. Jede Ergebnisart benutzt eine einzige Primitive.

ts
const activate = useCallback(
(result: Result) => {
if (result.kind === "generator") {
openWindow({
kind: "generator",
generatorId: result.generator.id,
});
handleClose();
return;
}
if (result.kind === "preset") {
const gen = result.generator;
if (!gen) { handleClose(); return; }
const target = new URL(buildShareUrl(gen.id, result.preset.config));
target.searchParams.set("preset", result.preset.id);
router.push(target.pathname + target.search);
handleClose();
return;
}
// Download: Generator mit der gespeicherten Config wiedereröffnen.
const gen = result.generator;
if (!gen) { handleClose(); return; }
const target = new URL(buildShareUrl(gen.id, result.download.config));
router.push(target.pathname + target.search);
handleClose();
},
[openWindow, router, handleClose],
);

Generatoren aktivieren mit openWindow(...), derselben Primitive, die ein Klick im Dock benutzt. Presets und Downloads navigieren zu einer config-codierten URL; der Route-Shim unter /generators/<id> greift den ?config=…-Query auf und dispatcht seinen eigenen openWindow beim Mounten. Der kürzeste Weg von "ich habe drei Buchstaben getippt" zu "das gewünschte Fenster liegt obenauf" läuft durch dieselbe Verkabelung wie jeder andere Eingang.

Der Zustand mit leerer Suche ist auch eine kleine Lektion. Ohne Query zeigt Spotlight alle Generatoren plus die fünf neuesten Presets und die fünf neuesten Downloads. Es funktioniert also gleichzeitig als "was habe ich kürzlich angefasst"-Oberfläche und ersetzt den Downloads-Ordner und das Presets-Menü, wenn du nur zu etwas zurück willst.

Ein kleiner Bonus: ein Custom Event namens SPOTLIGHT_OPEN_EVENT lässt jede andere Komponente (das Such-Icon in der Menüleiste, ein "Finden"-Button irgendwo auf einer Seite) die Palette öffnen, ohne dass man einen Callback weiterreichen muss. Dasselbe Muster wie die Storage-Change-Events aus dem vorherigen Abschnitt. Browser-Events sind hervorragender Komponenten-Klebstoff, wenn die Alternative ist, einen Callback durch drei Context-Schichten zu fädeln.

Animationen als Beleg

Echte Betriebssysteme verbringen den größten Teil ihres Motion-Budgets mit drei Dingen: Öffnen, Schließen und Minimieren. Jede Animation ist ein kleiner Beleg dafür, dass das gerade Geschehene eine echte Aktion mit einem echten Ziel war.

Die Minimieren-Animation ist die, auf die ich am stolzesten bin. macOS hat den "Genie"-Effekt: das Fenster verkleinert sich und gleitet in seine Dock-Kachel. Bewegung plus Ziel zusammen vermitteln "dieses Fenster ist nicht weg, es ist dort drüben geparkt". Ohne das Ziel sieht Minimieren wie ein Absturz aus.

Bevor in Mintables die Animation läuft, vermessen wir das DOM-Rechteck der Dock-Kachel und setzen das Delta als CSS-Custom-Properties auf das Fenster.

ts
const handleMinimize = () => {
const win = winRef.current;
if (win) {
const dockTile = document.querySelector(
`nav[aria-label="App dock"] [aria-label="${title}"]`,
);
if (dockTile) {
const dockRect = dockTile.getBoundingClientRect();
const winRect = win.getBoundingClientRect();
const dx =
dockRect.left + dockRect.width / 2 -
(winRect.left + winRect.width / 2);
const dy =
dockRect.top + dockRect.height / 2 -
(winRect.top + winRect.height / 2);
win.style.setProperty("--min-dx", `${dx.toFixed(0)}px`);
win.style.setProperty("--min-dy", `${dy.toFixed(0)}px`);
}
}
setPhase("minimizing");
window.setTimeout(() => {
onMinimize();
setPhase("open");
}, 380);
};

Die CSS-Transition greift var(--min-dx) und var(--min-dy) aus der minimizing-Phase auf.

ts
const phaseTransform: Record<LifecyclePhase, string> = {
opening: "translateY(8px) scale(0.985)",
open: "scale(1)",
closing: "translateY(-4px) scale(0.97)",
minimizing:
"translate3d(var(--min-dx, 0), var(--min-dy, 24vh), 0) scale(0.06)",
};

Das Fenster bleibt während des Minimierens gemountet, nur mit scale(0.06) und Opacity 0 gerendert, Pointer-Events aus. So bleibt der gesamte interne Shell-State (Vorschau-Kameraposition, Undo-Stack, bearbeitete Config) erhalten, und das Wiederherstellen ist sofort da. Das Wiederherstellen läuft dieselbe Animation rückwärts.

Das Wallpaper hat Cursor-Parallax (ein paar Pixel Verschiebung beim Mousemove) plus einen langsamen Fade-in beim ersten Paint. Dock-Kacheln heben sich beim Hover. Die kleinen Dinge. Nichts davon ist neu. Zusammen ergibt sich eine UI, die sich beschwert anfühlt, als würde Klicken etwas Echtes berühren.

Der Tubes-Generator als schwebendes Fenster. Linker Pane zeigt Controls für Innendurchmesser, Außendurchmesser, Länge, Endschnitte. Rechter Pane zeigt ein Live-3D-Render eines zylindrischen Rohrs mit Bemaßung in Millimetern.
Ein Generator-Fenster im Einsatz. Controls links, Live-Vorschau rechts, Bemaßung direkt am Geometriebild.

Was du eintauschst, was du gewinnst

Eine Web-App als Desktop umzudenken ist nicht umsonst.

Du gibst die Hauptaffordances der Seiten-Metapher auf. Tiefes Scrollen ist weg (der Arbeitsbereich ist ein fester Viewport). Hero-Seiten und Marketing-Text haben kein Zuhause. Externes SEO interner Ansichten schwächt sich ab, weil jede "Seite" ein Fenster über einer einzigen Shell ist. Der erste Eindruck für einen Erstbesucher ist "was schaue ich hier an", nicht "ah, eine Website".

Du schreibst auch mehr Code. Den Window-Manager, die Arbeitsbereichs-Arithmetik, die URL-Synchronisation, die geteilte Canvas-Verkabelung, das Fokus-Modell, die Tastatur-Shortcuts, den Dock-Indicator-State, die Genie-Animation, den File-Explorer mit Selection und Rename. Nichts davon kommt aus einem Router oder einer UI-Library. Hinter jedem dieser Features steckt eine selbst geschriebene Implementierung.

Was du zurückbekommst, ist die Umkehrung dessen, was die meisten Web-UIs standardmäßig aufgeben. Echtes Multitasking: ein Tubes-Fenster und ein Downloads-Fenster gleichzeitig offen, das Modell vor dir mit einem früheren Export vergleichend. Echter Fokus: ein einzelnes Fenster besitzt den Input, bis du woanders klickst. Echte Tastatur-Ergonomie: Cmd+1..9 springt zu Apps, Cmd+W schließt das vordere Fenster, Cmd+M minimiert, Cmd+K öffnet Spotlight. Alles über einen einzigen globalen Keydown-Listener verkabelt, der bei Inputs und Contenteditables aussteigt. Ein echter Ortssinn: kommt der Nutzer morgen zurück, ist der Desktop dort, wo er ihn verlassen hat, und die Kappe, die er gestern angepasst hat, ist offen und wartet auf weitere Änderungen.

Die größte qualitative Verschiebung ist, dass der Nutzer aufhört, die App zu navigieren, und anfängt, sie zu bewohnen. Er lernt einmal, wo die Dinge sind, und handelt dann, statt sich durch einen Pfad zu klicken. Modale Unterbrechungen hören auf zu existieren. Seitenleisten hören auf zu existieren. Ein großer Teil der Fläche, auf die du Material-Politur verteilt hättest, hört auf zu existieren.

Das Schwerste ist nicht der Window-Manager oder das geteilte Canvas. Das Schwerste ist, der Sidebar-und-Content-Form zu widerstehen, nach der jede Web-App standardmäßig greift.


S
Geschrieben von
Sascha Becker
Weitere Artikel