レトロデスクトップ風 UI の実装メモ
自分のブログのトップページを Windows 風のデスクトップ UI にした話。
ドラッグで動かせるウィンドウ、✕ボタンで閉じる、スタートメニューから再起動、ウィンドウのクリックで前面に来る、タスクバーに時計。
全部 React + Tailwind でゼロから組んだよ
どんな機能?
- ページ全体がデスクトップ。各コンテンツ(自己紹介、記事一覧、天気操作 etc.)が個別のウィンドウで表示される
- ウィンドウはタイトルバーをドラッグして自由に移動できる(モバイルは塞いでる)
- ✕ ボタンで閉じる → スタートメニューから再度開ける
- ウィンドウをクリックすると z-index が上がって前面に来る
- 画面下部にタスクバー。Start ボタン + 時計
- モバイルでは縦積みレイアウトに自動切り替え(ドラッグ無効、全幅表示)
- ウィンドウの追加は設定ファイル1箇所 + コンテンツ1行で完了
アーキテクチャ
page.tsx
├── Desktop
│ ├── WindowManagerProvider (Context: 開閉状態 + zIndex)
│ ├── DesktopInner
│ │ ├── RetroWindow × N (各ウィンドウ)
│ │ │ └── {contents[id]} (中身は外から注入)
│ │ └── Taskbar
│ │ ├── StartMenu (ウィンドウ一覧 + 起動ボタン)
│ │ └── Clock
│ └── useIsMobile() (レスポンシブ切り替え)
└── windowRegistry.ts (ウィンドウ定義の単一ソース)Q: なんで中身は外から注入する形にしたの?
A: 既存のコンポーネントを作り直すの面倒だったから
wapperみたいな形を取ればフレームだけ外付けできるじゃ〜んって気がついてそうした。
例の如くまたContext増やしちゃった。状態を遠い位置のコンポーネントに持って行きたい時に毎回使ってしまう。
各レイヤーの解説
1. ウィンドウ定義 — windowRegistry.ts
全ウィンドウの設定を1箇所にまとめた設定ファイル。新しいウィンドウを追加するときはここだけ触ればいい。
// app/config/windowRegistry.ts
export type WindowId = "about-me" | "recent-posts" | "yaogoromo" | "drawing-canvas";
export type WindowDef = {
id: WindowId;
title: string; // タイトルバーに表示 (about_me.txt とか)
icon: string; // スタートメニューの絵文字
color: TitleBarColor; // タイトルバーの色テーマ
initialOpen: boolean; // 初期状態で開いてるか
initialPosition: { x: number; y: number }; // PC時の配置座標
mobileClassName?: string;
desktopClassName?: string;
};
export const WINDOW_REGISTRY: WindowDef[] = [
{
id: "about-me",
title: "about_me.txt",
icon: "👤",
color: "pink",
initialOpen: true,
initialPosition: { x: 60, y: 40 },
},
// ...
];ユーティリティ関数もここに:
// レジストリから初期状態の Record を生成
export function buildInitialWindows() {
const windows: Record<string, { open: boolean; zIndex: number }> = {};
let z = 0;
for (const def of WINDOW_REGISTRY) {
if (def.initialOpen) z++;
windows[def.id] = { open: def.initialOpen, zIndex: def.initialOpen ? z : 0 };
}
return windows;
}初期状態の zIndex は initialOpen: true なウィンドウに順番に振っていくだけ。閉じてるウィンドウは zIndex: 0。
2. ウィンドウ状態管理 — WindowManager Context
// app/contexts/WindowManager.tsx
type WindowState = {
open: boolean;
zIndex: number;
};
type WindowManagerContextType = {
windows: Record<WindowId, WindowState>;
closeWindow: (id: WindowId) => void;
openWindow: (id: WindowId) => void;
focusWindow: (id: WindowId) => void;
};3つの操作だけ:
closeWindow — open: false にするだけ。zIndex はそのまま。
const closeWindow = useCallback((id: WindowId) => {
setWindows((prev) => ({
...prev,
[id]: { ...prev[id], open: false },
}));
}, []);openWindow — open: true にしつつ topZ + 1 を振って最前面に。
const openWindow = useCallback((id: WindowId) => {
const newZ = topZ + 1;
setTopZ(newZ);
setWindows((prev) => ({
...prev,
[id]: { open: true, zIndex: newZ },
}));
}, [topZ]);focusWindow — 既に開いてるウィンドウをクリックした時。zIndex だけ更新。
const focusWindow = useCallback((id: WindowId) => {
const newZ = topZ + 1;
setTopZ(newZ);
setWindows((prev) => ({
...prev,
[id]: { ...prev[id], zIndex: newZ },
}));
}, [topZ]);topZ は「今までに振った最大の zIndex」を useState で持ってて、操作のたびに +1 していく。要は「最後に触ったウィンドウが一番前」になる。OS のウィンドウマネージャーと同じ考え方。
3. RetroWindow — ウィンドウの見た目
状態管理を一切知らない、純粋な表示コンポーネント。
// app/components/RetroWindow/RetroWindow.tsx
type Props = {
title: string;
color?: TitleBarColor;
children: React.ReactNode;
draggable?: boolean;
initialPosition?: { x: number; y: number };
zIndex?: number;
onClose?: () => void;
onFocus?: () => void;
};タイトルバーの色テーマ
4色のグラデーション設定を用意:
const BAR_COLORS: Record<TitleBarColor, { light: string; dark: string; deep: string }> = {
pink: { light: "#eebbc3", dark: "#ff69b4", deep: "#fb379f" },
blue: { light: "#232946", dark: "#4a6fa5", deep: "#1e2a78" },
teal: { light: "#2a9d8f", dark: "#40c9a2", deep: "#1e776f" },
orange: { light: "#f2c57c", dark: "#e09f3e", deep: "#d17c2a" },
};light → dark の横グラデーションでタイトルバーを塗る。deep は ✕ ボタンのホバー色。
なんか命名もうちょっといいのないかなこの辺
ドラッグの仕組み
Pointer Events で実装。タイトルバーの onPointerDown でドラッグ開始:
const handlePointerDown = (e: PointerEvent) => {
dragRef.current = {
startX: e.clientX, // ドラッグ開始時のマウス位置
startY: e.clientY,
originX: pos.x, // ドラッグ開始時のウィンドウ位置
originY: pos.y,
};
e.target.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: PointerEvent) => {
if (!dragRef.current) return;
setPos({
x: dragRef.current.originX + (e.clientX - dragRef.current.startX),
y: dragRef.current.originY + (e.clientY - dragRef.current.startY),
});
};setPointerCapture がミソ。これがないとマウスがウィンドウ外に出た瞬間にドラッグが途切れる。キャプチャすることで、ポインターがどこにいても move イベントがタイトルバーに届く。
draggable が false(モバイル時)なら全部スキップ。
ウィンドウの装飾
<div className="rounded-lg border-2 border-illustration-stroke
shadow-[4px_4px_0px_0px_rgba(18,22,41,0.6)]">shadow-[4px_4px_0px_0px] のハードシャドウでレトロ感を出してる。角丸 + 2px ボーダー + ドロップシャドウの組み合わせ。
角丸のせいで若干見切れている気もしているが、一旦ヨシ!!!
✕ ボタンの横にある ─ と □ は飾り。最小化・最大化は実装してない(面倒くさいので要らないんじゃないか)
コンテンツ部分は背景の空が透けて見えるように半透明:
<div className="bg-elements-background/90 backdrop-blur-sm">
{children}
</div>4. Desktop — コンテンツとウィンドウの接続
ウィンドウ定義とコンテンツを結びつける場所。
// app/_sections/Desktop/Desktop.tsx
type Props = {
contents: Partial<Record<WindowId, ReactNode>>;
};
function DesktopInner({ contents }: Props) {
const { windows, closeWindow, focusWindow } = useWindowManager();
const isMobile = useIsMobile();
return (
<>
<main className={isMobile
? "flex flex-col items-center gap-6 p-6 pb-14"
: "h-[calc(100dvh-2.5rem)] relative overflow-hidden"
}>
{WINDOW_REGISTRY.map((def) => {
if (!windows[def.id].open) return null;
const content = contents[def.id];
if (!content) return null;
return (
<RetroWindow
key={def.id}
title={def.title}
color={def.color}
draggable={!isMobile}
initialPosition={!isMobile ? def.initialPosition : undefined}
zIndex={!isMobile ? windows[def.id].zIndex : undefined}
onClose={() => closeWindow(def.id)}
onFocus={() => focusWindow(def.id)}
>
{content}
</RetroWindow>
);
})}
</main>
<Taskbar />
</>
);
}WINDOW_REGISTRY.map() でレジストリを回して、開いてるウィンドウだけ描画する。レジストリの順序 = デフォルトの描画順。zIndex でそこから前後が変わる。
コンテンツは page.tsx から注入:
// app/page.tsx
<Desktop
contents={{
"about-me": <AboutMe />,
"recent-posts": <Suspense fallback={...}><PostList /></Suspense>,
yaogoromo: <WeatherControl />,
"drawing-canvas": <DrawingCanvas />,
}}
/>ウィンドウシステムは中に何が入るか一切知らない。Composition パターン。
5. Taskbar + StartMenu
タスクバー
// app/components/Taskbar/Taskbar.tsx
<footer className="fixed bottom-0 left-0 right-0 z-50 h-10
bg-elements-background/90 backdrop-blur-md
border-t-2 border-illustration-stroke">画面下部に固定。高さ 40px。半透明 + ブラー。
Start ボタンを押すたびにランダム絵文字が変わる無駄な機能:
const EMOJI_LIST = ["🌊", "🚀", "🌟", "🎉", "🦕", "🍕", "⚡️", "💻"];
// クリック時
setEmoji(EMOJI_LIST[Math.floor(Math.random() * EMOJI_LIST.length)]);
setMenuOpen((v) => !v);右端にはミニマルな時計。10 秒ごとに更新:
function Clock() {
const [time, setTime] = useState("");
useEffect(() => {
const update = () => {
setTime(new Date().toLocaleTimeString("ja-JP", {
hour: "2-digit", minute: "2-digit",
}));
};
update();
const id = setInterval(update, 10000);
return () => clearInterval(id);
}, []);
return <span>{time}</span>;
}スタートメニュー
// app/components/Taskbar/StartMenu.tsx
{WINDOW_REGISTRY.map((def) => {
const isClosed = !windows[def.id].open;
return (
<button onClick={() => { openWindow(def.id); onClose(); }}>
<span>{def.icon}</span>
<span>{def.title}</span>
{isClosed && <span className="ml-auto text-[10px]">起動</span>}
</button>
);
})}WINDOW_REGISTRY をそのまま回すので、ウィンドウを追加すればスタートメニューにも自動で出る。
開いてるウィンドウはテキストが薄くなり、閉じてるウィンドウは「起動」ラベルが出る。
メニュー外クリックで閉じる処理は pointerdown イベントで menuRef.contains() チェック:
useEffect(() => {
if (!menuOpen) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("pointerdown", handleClick);
return () => document.removeEventListener("pointerdown", handleClick);
}, [menuOpen]);6. レスポンシブ — useIsMobile
// app/hooks/useIsMobile.ts
export function useIsMobile() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}useSyncExternalStore で matchMedia("(max-width: 768px)") を監視。SSR 時は false を返す。
モバイル時の挙動:
- ウィンドウは
flex-colで縦積み(ドラッグなし、zIndex なし) mobileClassNameが適用されて全幅表示- タスクバーは同じだがウィンドウのクリック前面化は無効
ファイル構成
| ファイル | 役割 |
|---|---|
app/config/windowRegistry.ts | ウィンドウ定義の単一ソース(型 + 設定配列 + ユーティリティ) |
app/contexts/WindowManager.tsx | 開閉・zIndex の状態管理 Context |
app/components/RetroWindow/RetroWindow.tsx | ウィンドウの見た目 + ドラッグ |
app/_sections/Desktop/Desktop.tsx | レジストリとコンテンツの接続 + レスポンシブ切り替え |
app/components/Taskbar/Taskbar.tsx | 画面下部のタスクバー + 時計 |
app/components/Taskbar/StartMenu.tsx | スタートメニュー(ウィンドウ一覧 + 起動) |
app/hooks/useIsMobile.ts | モバイル判定フック |
app/hooks/useIsClient.ts | SSR/クライアント判定フック |
app/page.tsx | Desktop に contents を渡す |
設計で意識したこと
単一ソースの定義ファイル
ウィンドウの追加に必要な変更は 2 箇所だけ: windowRegistry.ts に定義を追加 + page.tsx でコンテンツを渡す。スタートメニューの項目、初期状態、zIndex 計算、レスポンシブクラスは全部レジストリから自動生成される。「ここだけ触ればOK」を徹底した。
見た目と状態の分離
RetroWindow は open/close の概念を知らない。渡された props(title, color, zIndex, onClose)で描画するだけ。Desktop が「開いてなければ render しない」を判断してるので、RetroWindow は汎用的なウィンドウ枠として他の場所でも使い回せる。
zIndex のインクリメント方式
ウィンドウの前後関係を topZ のカウンタ1つで管理。タッチ/クリックのたびに +1 して、触ったウィンドウが必ず最前面に来る。OS のウィンドウマネージャーと同じアルゴリズムで、何個ウィンドウがあっても破綻しない。理論上は整数がオーバーフローするまで大丈夫(現実的には問題にならない)。
Pointer Events の採用
かなりいいぞ: https://developer.mozilla.org/ja/docs/Web/CSS/Reference/Properties/pointer-events
マウスとタッチを統一的に扱える Pointer Events API を使ってる。setPointerCapture でドラッグ中のポインターをロックすることで、高速にマウスを動かしてウィンドウ外に出てもドラッグが途切れない。Touch Events と Mouse Events を別々にハンドルする必要がなくなった。
モバイルでの割り切り
モバイルでは「デスクトップっぽさ」を捨てて実用性に振った。いたしかたなし。
Composition パターン
Desktop コンポーネントはウィンドウの中身を一切知らない。contents: Record<WindowId, ReactNode> で外から注入される。つまりウィンドウシステムとコンテンツが完全に独立してて、<Suspense> でラップしたり、任意の React コンポーネントを詰め込める。フレームワークとコンテンツの関心分離。