A v0.app / Vercel 2D-web starter for the Meta Quest browser. A modern Next.js + shadcn/ui app that is dark, legible, comfortable, installable as a PWA, and multi-panel responsive — no WebXR/3D dependencies.
For immersive VR/AR, use the sibling
quest-webxr-iwsdktemplate (Vite + Meta's Immersive Web SDK).
Built on Next.js (App Router) · React 19 · TypeScript · Tailwind CSS v4 · shadcn/ui (new-york).
| Area | Included |
|---|---|
| Design tokens | Dark-first theme tuned to the Quest LCD (no pure black/white), generous radii, a touch spacing scale (48 / 56px), larger root font for VR legibility |
| Multi-panel | PanelGroup + usePanelSize reflow the layout live via ResizeObserver; QuestPanel fills the Horizon OS window edge-to-edge |
| Multi-window | lib/window-bus.ts + useSharedWindowState coordinate separate browser windows (placed around you in 3D space) via postMessage + BroadcastChannel |
| PWA | app/manifest.ts (landscape, standalone, maskable icons), public/sw.js offline service worker, /offline fallback, generated icons |
| Components | shadcn/ui primitives (Button w/ Quest xl/icon-touch sizes, Card, Badge, Switch, Separator, ScrollArea) + theme toggle |
| Registry | registry.json so the Quest components install via the shadcn CLI and Open in v0 |
| Docs | docs/QUEST_GUIDELINES.md — the full Quest web checklist with sources |
Routes: / landing · /panels multi-panel demo · /windows multi-window demo (+ /windows/detail).
npm install
npm run icons # generate PWA icons (zero deps; already committed)
npm run dev # http://localhost:3000
# PWA install requires a secure context:
npm run dev:https # https://localhost:3000
Then open the URL in the Meta Quest browser. For on-device debugging, use
chrome://inspect#devices.
app/
layout.tsx # metadata, viewport, theme provider, SW registration
page.tsx # landing (feature grid via PanelGroup)
panels/page.tsx # multi-panel responsive demo
windows/ # multi-window demo (page = controller, detail/ = child, demo-ui.tsx = shared)
manifest.ts # PWA manifest → /manifest.webmanifest
offline/page.tsx # offline fallback
globals.css # Tailwind v4 + Quest-tuned design tokens
components/
ui/ # shadcn/ui primitives
quest/ # QuestPanel, PanelGroup
theme-provider.tsx, theme-toggle.tsx, register-sw.tsx, open-in-v0-button.tsx
hooks/ # usePanelSize, useSharedWindowState
lib/ # utils (cn), quest (platform constants), window-bus (cross-window messaging)
public/ # sw.js, icons/
scripts/ # generate-icons.mjs
registry.json # shadcn / v0 registry
npx shadcn@latest build # outputs public/r/*.json
Once deployed, install a component into any shadcn project:
npx shadcn@latest add https://your-deploy.vercel.app/r/panel-group.json
…or wire up an Open in v0 button (included as
components/open-in-v0-button.tsx):
<OpenInV0Button url="https://your-deploy.vercel.app/r/quest-panel.json" />
Note: the v0 "open" endpoint doesn't apply per-item
cssVars/css/envVars, so this template keeps theme tokens inapp/globals.css.
Two different things, both useful on Quest:
- Multi-panel (
/panels) — one window whose layout reflows into multiple columns as it's resized (PanelGroup+usePanelSize). One browsing context. - Multi-window (
/windows) — several browser windows the user arranges anywhere around them in 3D space, each its own browsing context, coordinated with each other. Open one withwindow.open(); keep them in sync over aBroadcastChannel(works even when COOP nullswindow.opener), with directwindow.postMessageavailable for targeting a specific window or sending zero-copyTransferables (e.g. anImageBitmap).
The plumbing is lib/window-bus.ts (a tiny typed bus) and the
useSharedWindowState hook. The demo shares a { count, accent, note } object:
change it in any window — controller or detached — and every window updates live.
import { useSharedWindowState } from "@/hooks/use-shared-window-state";
const { state, setState, openWindow } = useSharedWindowState("my-app", { count: 0 });
// open a window the user can place in space; it syncs automatically:
openWindow("/windows/detail", "detail", "popup,width=900,height=720");
setState((s) => ({ ...s, count: s.count + 1 })); // broadcasts to every window
Note:
window.open()must be called from a user gesture (a click). For very large/streamed payloads, preferbus.postTo(win, data, [transferable])to move bytes without copying.
- Hit targets: use
size="xl"/size="icon-touch"on Button, or themin-h-touch/size-touchutilities, to meet the 48px minimum. - Dark by default: the theme is tuned for the Quest LCD — see the comments in
app/globals.css. - Fluid layout: assume your page is one resizable panel among several; the layout reflows from ~500px to 2000px wide.
- Going further: read
docs/QUEST_GUIDELINES.mdfor layout, input, and PWA packaging (Bubblewrap → Meta Horizon Store) guidance.
MIT — use it as a starting point for anything.