Skip to main content
55d781a5efe6ab9376d9
·4 min read

Building an OS-Style Window Layout in React

Build a desktop-like window system in React with Zustand & Framer Motion. Covers draggable windows, a dynamic taskbar, declarative config, and clean state architecture.

react
typescript
window
zustand
store
4d5fd787ac74e0caa4f7

Sohan R. Emon

Developer, Learner, Tech Enthusiast

The Core Idea

The system centers on a few pieces that work together: a Zustand store for window state, a config file that declares what each window is, and a few UI components that handle the visual side.

Here's how I approached building a desktop-like windowing system inside a React application. You can try a live version at /os.

Managing Windows with State

Every window can be open, closed, minimized, or maximized. The Zustand store keeps track of all of them, including their sizes and z-index order. When you open a window, it gets the highest z-index and appears on top. If you click a window that is already focused, it shakes instead of doing nothing. It is a small detail but it gives feedback.

tsx
openWindow: (id) =>
  set(({ windows }) => ({
    windows: windows.map((w) => {
      if (w.id !== id) return w;
      if (w.status === "closed" || w.status === "minimized") {
        return { ...w, status: "open", zIndex: topZ(windows) + 1 };
      }
      if (w.zIndex === maxZ) return { ...w, isShaking: true };
      return { ...w, zIndex: topZ(windows) + 1 };
    }),
  })),

A helper hook called useWindowControl wraps the store actions for a single window. And useIsFocused tells you which window is on top so you can highlight its title bar.

Window Definitions

Instead of hardcoding each window's content, I used a schema. Each entry declares the id, title, icon, default size, position, and the React element to render inside.

tsx
{
  id: "hero",
  title: "~/sohanscript",
  icon: "lucide:terminal",
  initialPosition: { left: 150, bottom: 200 },
  element: <Hero bare />,
}

Some windows link to separate pages. Those use a string URL instead of a React element. The click handler checks which type it is and either opens the window or sends it to the router.

The Window Component

Each window is a motion.div from Motion (Framer Motion). You can drag it by the title bar, resize it from any edge or corner, and it animates when minimizing or maximizing.

tsx
<motion.div
  drag={!maximized && !minimized}
  dragControls={controls}
  style={{ x, y, zIndex, width, height }}
  onPointerDown={() => bringToFront()}
>
  {/* title bar with macOS action buttons */}
  {/* content area */}
</motion.div>

The resize handles are thin invisible buttons along every edge and corner. On mousedown they track the mouse and update the window dimensions through the store.

The Taskbar

A sidebar dock lists all the apps. Each icon shows an indicator when its window is open. It dims when the window is minimized. Clicking an app opens it if closed, brings it to front if open, and reopens it if minimized.

tsx
<UbuntuDash
  apps={WINDOW_CONFIGS.map((cfg) => ({
    id: cfg.id, icon: cfg.icon, name: cfg.title,
  }))}
  onAppClick={(id) => {
    const win = windows.find((w) => w.id === id);
    if (!win || win.status === "closed" || win.status === "minimized") {
      openWindow(id);
    } else {
      bringToFront(id);
    }
  }}
/>

There is also an app grid overlay. A button at the bottom of the dash opens it and gives you a full screen launcher view.

Putting It Together

The parent component closes all windows first for a clean slate, then renders the taskbar and a WindowRenderer that filters the store and maps each open window to a Window component.

tsx
function OSEnvironment() {
  const { openWindow, closeAllWindow } = useWindowStore();
  useEffectOnce(closeAllWindow);

  return (
    <>
      <WindowRenderer />
      <UbuntuDash apps={...} onAppClick={...} />
    </>
  );
}

The WindowRenderer lazy loads the Window component so you are not pulling in heavy animation dependencies upfront.

tsx
const Window = lazy(() =>
  import("./window").then((m) => ({ default: m.Window })),
);

export function WindowRenderer() {
  const windows = useWindowStore((s) => s.windows);
  return windows
    .filter((w) => w.status !== "closed")
    .map((win) => {
      const cfg = WINDOW_CONFIGS.find((c) => c.id === win.id);
      return (
        <Window key={win.id} id={win.id}>
          {cfg.element}
        </Window>
      );
    });
}

What You Get

The result is a reusable windowing layer. Windows manage themselves through the store, the taskbar shows real time status, and adding a new window is just another config entry. The router handles page level navigation. The window manager handles on screen layout. They only meet in the click handler.

That separation is the part worth keeping. The window manager does not know about routing. The router does not know about window positions.

Found this useful?