diff --git a/.gitignore b/.gitignore
index 06ef544..d08720d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@
!/docs/
/docs/*
!/docs/adoption.md
+!/docs/components.md
!/docs/releasing.md
!/docs/design-locks/
/docs/design-locks/*
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bf8d986..dbb17dc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,8 +1,10 @@
# Contributing
-Use Node.js 24+, pnpm 10.33.2 and `pnpm install --frozen-lockfile`, then `pnpm dev` for the demo app. The landing page, playground and showcase share one Next.js app. Run `pnpm check` before opening a pull request. Keep the library and demo responsibilities separate.
+Use Node.js 24+, pnpm 10.33.2 and `pnpm install --frozen-lockfile`, then `pnpm dev` for the demo app. The landing page, playground, showcase and `/components` guide share one Next.js app. Run `pnpm check` before opening a pull request. Keep the library and demo responsibilities separate.
-The demo deliberately consumes the pinned public alpha from npm. Local library edits do not change that installed demo version. Use `pnpm pack:local` and an independent consumer to verify a new package build before release. Do not silently replace the demo's published dependency with a source alias.
+The demo consumes the built workspace package through `workspace:*`. Its `dev`, `build` and `typecheck` scripts prepare the library's ignored `dist` exports before running Next.js, including from `apps/demo`. Rebuild with `pnpm --filter react-glaze build` after editing library source while the demo server is running.
+
+Use `pnpm pack:local` and an independent consumer that installs the resulting tarball to verify a new package build before release. That archive check verifies packaging separately from workspace development; see the [release procedure](docs/releasing.md).
For optical changes, include a minimal reproduction and before/after browser evidence. Distinguish native Safari, Playwright WebKit, viewport emulation, Simulator and physical devices. Do not infer FPS from JavaScript submission timings.
diff --git a/README.md b/README.md
index 0da4916..00fc412 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
[](https://react-glaze.app/)
-[Website](https://react-glaze.app/) · [Playground](https://react-glaze.app/playground) · [Showcase](https://react-glaze.app/showcase) · [npm](https://www.npmjs.com/package/react-glaze)
+[Website](https://react-glaze.app/) · [Components](https://react-glaze.app/components) · [Liquid Glass editor](https://react-glaze.app/components?component=glass) · [Showcase](https://react-glaze.app/showcase) · [npm](https://www.npmjs.com/package/react-glaze)
-A configurable React 19 component that refracts the page behind your content. Use a native button, link or ordinary wrapper, keep your own CSS, and adjust the material, shape and optional pointer-driven rim light.
+Liquid glass for React 19: a configurable wrapper, morphing menus, spring-driven actions and composable surfaces. Keep native content and your own CSS, and tune the material and motion.
**Experimental.** Desktop Chrome, Firefox and Safari are the targets; iPhone Safari is experimental. Background capture is asynchronous and has [documented limitations](packages/react/README.md#known-limits).
@@ -35,6 +35,12 @@ export function SaveButton() {
No provider, background image prop or stylesheet import is required. Your content controls the size; your CSS controls layout and typography. Next.js App Router can render the library's client boundary from a server component. Event handlers belong in your own client component.
+## Morphing components
+
+`LiquidGlassActions` opens a row, column or fan of native buttons. `LiquidGlassMenu` turns one trigger into a menu panel. Both use `LiquidGlassGroup` and `LiquidGlassSurface`, which are also available for building your own interactions. Adjust action size, signed trigger distance, refraction, exact duration and bounce independently.
+
+[Component guide and examples](docs/components.md) · [Interactive components](https://react-glaze.app/components)
+
## Customize
- **Materials:** reference, quiet, frosted and Optical Type presets, with individual optical controls.
@@ -50,12 +56,13 @@ Use Node.js 24 or newer:
```sh
pnpm install --frozen-lockfile
+pnpm --filter react-glaze build
pnpm dev
```
-Open http://127.0.0.1:8862. One Next.js app serves the landing page, `/playground` and `/showcase`.
+Open http://127.0.0.1:8862. One Next.js app serves the landing page, `/components` and `/showcase`. `/playground` remains a compatibility redirect to `/components?component=glass`.
-The playground includes material/rim controls, custom backgrounds, generated JSX/JSON and shareable URLs. Save one configuration locally for your next visit; explicit shared URLs take priority. Uploaded images stay in the current tab. Saved configurations belong to the browser origin; using the same host and port as the previous preview preserves access to them.
+The Liquid Glass editor at `/components?component=glass` includes material/rim controls, custom backgrounds, generated JSX/JSON and shareable URLs. Save one configuration locally for your next visit; explicit shared URLs take priority. Uploaded images stay in the current tab. Saved configurations belong to the browser origin; using the same host and port as the previous preview preserves access to them.
The Roam showcase exercises responsive photographs, image changes, navigation, native dialogs and trip planning over real page content. Its saved trips stay local to your browser.
@@ -66,7 +73,7 @@ pnpm check
pnpm pack:local
```
-`packages/react` contains the library. `apps/demo` is a Next.js consumer pinned to the published `react-glaze@0.1.1` package. Its import resolves to the registry package, while the local library is built and tested separately. The workspace uses one pnpm lockfile.
+`packages/react` contains the library. `apps/demo` consumes its built workspace package during development and CI. An independently installed tarball consumer verifies the distributable boundary separately. The workspace uses one pnpm lockfile. Build the package before starting the demo after library changes.
Checks cover package tests and public types, playground configuration/storage tests, workspace type checks and the demo production build. Local archives are written to unique directories so previous builds remain available. See the [demo guide](apps/demo/README.md) for routes and styling, and the [design lock](docs/design-locks/2026-09-09-demo.md) for the landing direction and verification record.
diff --git a/apps/demo/README.md b/apps/demo/README.md
index 1b78325..6129d43 100644
--- a/apps/demo/README.md
+++ b/apps/demo/README.md
@@ -14,14 +14,18 @@ Open http://127.0.0.1:8862.
| Route | Purpose |
| --- | --- |
| `/` | One-screen introduction, real glass demo, installation and links |
-| `/playground` | Material/shape/rim editor, JSX/JSON export, shareable configuration and optional local save |
+| `/components?component=glass` | Material/shape/rim editor, JSX/JSON export, shareable configuration and optional local save |
| `/showcase` | Roam travel demo: photographs, gallery, filters, saved places and native trip-planning dialog |
+| `/components` | Interactive split actions, morphing menu, Liquid Glass editor and group/surface examples with copyable TSX |
+| `/playground` | Compatibility redirect to `/components?component=glass` |
| `/quality.html` | Preserved optical quality fixture |
| `/research.html` | Preserved material research fixture |
-The app uses the actual published `react-glaze@0.0.0-alpha.1` dependency. The monorepo disables automatic workspace linking so the demo exercises its public artifact. The local package remains available for independent builds, tests and archives.
+The app consumes `react-glaze` through `workspace:*`, using the library's built exports in `packages/react/dist`. The demo's `dev`, `build` and `typecheck` scripts build that dependency first, including when run directly from `apps/demo` after a fresh workspace install. After editing library source while the dev server is running, run `pnpm --filter react-glaze build` again to update those exports.
-The playground retains the owner's exact initial/reset configuration. Explicit `config` query parameters win over a manually saved local configuration. The storage key remains `react-glaze:playground:config:v1`. Save is manual, Reset does not overwrite the saved copy, Delete saved leaves the current preview intact, and uploaded image bytes are not stored. Storage belongs to an origin, so changing host or port does not transfer previous settings. Old `/?config=...` links redirect to `/playground` with their configuration preserved.
+Use `pnpm pack:local` and an independently installed tarball consumer to verify the distributable package separately. The workspace demo does not establish that an archive contains everything an external application needs. See the [release procedure](../../docs/releasing.md).
+
+The Liquid Glass editor retains the owner's exact initial/reset configuration. `/components` selects split actions by default; `component=actions|menu|glass` is addressable, and a `config` query without an explicit valid component selects the editor. Explicit `config` query parameters win over a manually saved local configuration. The storage key remains `react-glaze:playground:config:v1`. Save is manual, Reset does not overwrite the saved copy, Delete saved leaves the current preview intact, and uploaded image bytes are not stored. Storage belongs to an origin, so changing host or port does not transfer previous settings. Old `/?config=...` and `/playground` links redirect to `/components?component=glass` with their query values preserved.
Roam retains `liquid-showcase-trip-v1` for its local trip state. Photographs and fonts retain their [source attribution](ASSETS.md).
@@ -31,7 +35,7 @@ Generated with `@larsen-utvikling/create-next-app@0.6.0`, pnpm and the recommend
The single stylesheet chain is `src/app/layout.tsx` -> `globals.css` -> `src/lib/design-system/index.css`. Structural/type/motion tokens come from the template. The color file is the actual Canonical Palette download using #4655F5, automatic secondary, weak neutral tint, shadcn/ui HSL values, media dark mode and no Tailwind integration. [Token guide](DESIGN.md).
-The playground and showcase retain their visual identity in scoped route styles. Their palette variables and generic controls cannot leak into the landing page. The playground mounts its browser-only editor within a client-only loader; the route retains its own server-rendered metadata.
+The editor and showcase retain their visual identity in scoped route styles. Their palette variables and generic controls cannot leak into the landing page. The editor mounts its browser-only implementation within a client-only loader after its first selection; the Components route retains its own server-rendered metadata.
[Design lock and verification](../../docs/design-locks/2026-09-09-demo.md).
@@ -39,15 +43,15 @@ The playground and showcase retain their visual identity in scoped route styles.
Production traffic and adoption actions use Vercel Web Analytics. See the [event definitions, collection boundaries and dashboard](docs/analytics.md).
-Run `pnpm check` at the root for package/config/storage tests, type checks and the production build. Use `pnpm --filter demo build` to build only the site, and `pnpm --filter demo start` to preview that production build.
+Run `pnpm check` at the root for package/config/storage tests, type checks and the production build. Use `pnpm --filter demo build` for the site's production build, including its workspace package prerequisite, and `pnpm --filter demo start` to preview it. From `apps/demo`, the equivalent commands are `pnpm build` and `pnpm start`; `pnpm typecheck` also prepares the library before checking the app.
Desktop Chrome, Firefox and Safari are the package targets; iPhone Safari remains experimental. Local builds, browser checks, physical-device checks and deployment are separate evidence. No npm release or site deployment is triggered by these commands.
## Identity and search metadata
-The shared Join logo appears in the landing, playground and showcase branding. [Brand assets and regeneration](docs/brand.md) describe the single SVG geometry source, outlined wordmarks, native icons and README/social graphic.
+The shared Join logo appears in the landing, Components route and showcase branding. [Brand assets and regeneration](docs/brand.md) describe the single SVG geometry source, outlined wordmarks, native icons and README/social graphic.
Next.js file conventions serve `favicon.ico`, `icon.svg`, `apple-icon.png`, `manifest.webmanifest`, `robots.txt`, `sitemap.xml`, Open Graph and Twitter images. Metadata helpers define each route's title, description and production canonical URL. Child routes preserve the actual native social image metadata from their parent. The safely serialized JSON-LD graph describes the website and the open-source package without invented reviews, ratings or release claims.
-The three current routes are included in the sitemap. Historical research and quality fixtures remain accessible with `X-Robots-Tag: noindex, follow`; they are not blocked in robots.txt. The manifest uses browser display and does not promise an offline application. Verification and search-engine indexing are separate outcomes.
+The three public routes are included in the sitemap. Historical research and quality fixtures remain accessible with `X-Robots-Tag: noindex, follow`; they are not blocked in robots.txt. The manifest uses browser display and does not promise an offline application. Verification and search-engine indexing are separate outcomes.
diff --git a/apps/demo/package.json b/apps/demo/package.json
index 82e5623..5034823 100644
--- a/apps/demo/package.json
+++ b/apps/demo/package.json
@@ -3,18 +3,18 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev --hostname 127.0.0.1 --port 8862",
- "build": "next build",
+ "dev": "pnpm --filter react-glaze build && next dev --hostname 127.0.0.1 --port 8862",
+ "build": "pnpm --filter react-glaze build && next build",
"start": "next start --hostname 127.0.0.1 --port 8862",
"lint": "eslint",
- "typecheck": "next typegen && tsc --noEmit"
+ "typecheck": "pnpm --filter react-glaze build && next typegen && tsc --noEmit"
},
"dependencies": {
"@vercel/analytics": "^2.0.1",
"next": "16.3.4",
"react": "19.2.8",
"react-dom": "19.2.8",
- "react-glaze": "0.1.2"
+ "react-glaze": "workspace:*"
},
"devDependencies": {
"@types/node": "^20",
diff --git a/apps/demo/src/app/components/page.tsx b/apps/demo/src/app/components/page.tsx
new file mode 100644
index 0000000..2e7fcac
--- /dev/null
+++ b/apps/demo/src/app/components/page.tsx
@@ -0,0 +1,67 @@
+import type { ResolvingMetadata } from "next";
+import { Suspense } from "react";
+import Link from "next/link";
+import { BrandIcon } from "@/components/brand/BrandIcon";
+import { ComponentsExplorer } from "@/components/components/ComponentsExplorer";
+import { pageMetadata } from "@/lib/seo";
+
+export async function generateMetadata(
+ _props: unknown,
+ parent: ResolvingMetadata,
+) {
+ return pageMetadata("/components", await parent);
+}
+
+function ComponentsExplorerFallback() {
+ return (
+
+
+ Loading the component explorer...
+
+
+ );
+}
+
+export default function ComponentsPage() {
+ return (
+
+
+
+
+ React Glaze
+
+
+ Home
+
+ Components
+
+ Showcase
+
+
+
+
+
REACT GLAZE / COMPONENTS
+
+ One material.
+
+ Made to move.
+
+
+ Composable glass surfaces and fluid menus.
+
+ Native controls, shared motion, your content.
+
+
+ }>
+
+
+
+
+ React 19 / Open source / MIT
+
+ View the source
+
+
+
+ );
+}
diff --git a/apps/demo/src/app/manifest.ts b/apps/demo/src/app/manifest.ts
index 196e891..89a65f7 100644
--- a/apps/demo/src/app/manifest.ts
+++ b/apps/demo/src/app/manifest.ts
@@ -6,7 +6,7 @@ export default function manifest(): MetadataRoute.Manifest {
id: "/",
name: SITE_NAME,
short_name: SITE_NAME,
- description: "Liquid glass for React. Explore the playground and showcase.",
+ description: "Liquid glass for React. Explore components and showcase.",
lang: "en",
start_url: "/",
scope: "/",
diff --git a/apps/demo/src/app/page.tsx b/apps/demo/src/app/page.tsx
index 835b64b..f9e129a 100644
--- a/apps/demo/src/app/page.tsx
+++ b/apps/demo/src/app/page.tsx
@@ -3,22 +3,21 @@ import { redirect } from "next/navigation";
import { MaterialStage } from "@/components/landing/MaterialStage";
import { InstallCommand } from "@/components/landing/InstallCommand";
import { BrandIcon } from "@/components/brand/BrandIcon";
-import { pageMetadata } from "@/lib/seo";
+import { PACKAGE_URL, pageMetadata, REPOSITORY_URL } from "@/lib/seo";
import { ResourceLink } from "@/components/analytics/ResourceLink";
+import { redirectToComponents, type RouteSearchParams } from "@/lib/components-navigation";
export const metadata = pageMetadata("/");
export default async function Home({
searchParams,
}: {
- searchParams: Promise<{ config?: string | string[] }>;
+ searchParams: Promise;
}) {
- const { config } = await searchParams;
+ const query = await searchParams;
+ const { config } = query;
if (config !== undefined) {
- const query = new URLSearchParams({
- config: Array.isArray(config) ? config[0] : config,
- });
- redirect(`/playground?${query}`);
+ redirect(redirectToComponents(query));
}
return (
@@ -28,9 +27,14 @@ export default async function Home({
React Glaze
-
- GitHub
-
+
+
+ GitHub
+
+
+ npm
+
+
@@ -39,15 +43,15 @@ export default async function Home({
Liquid glass for React.
Wrap a button or a card.
- Keep your content and your own CSS.
+ Compose a menu. Keep your own CSS.
-
- Playground
- Tune the glass
+
+ Components
+ Explore and customize
Showcase
diff --git a/apps/demo/src/app/playground/page.tsx b/apps/demo/src/app/playground/page.tsx
index df40940..534aeca 100644
--- a/apps/demo/src/app/playground/page.tsx
+++ b/apps/demo/src/app/playground/page.tsx
@@ -1,11 +1,10 @@
-import type { ResolvingMetadata } from "next";
-import { PlaygroundLoader } from "@/components/playground/PlaygroundLoader";
-import { pageMetadata } from "@/lib/seo";
+import { redirect } from "next/navigation";
+import { redirectToComponents, type RouteSearchParams } from "@/lib/components-navigation";
-export async function generateMetadata(_props: unknown, parent: ResolvingMetadata) {
- return pageMetadata("/playground", await parent);
-}
-
-export default function PlaygroundPage() {
- return ;
+export default async function PlaygroundPage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ redirect(redirectToComponents(await searchParams));
}
diff --git a/apps/demo/src/components/analytics/ResourceLink.tsx b/apps/demo/src/components/analytics/ResourceLink.tsx
index 8315a4a..3ab46cc 100644
--- a/apps/demo/src/components/analytics/ResourceLink.tsx
+++ b/apps/demo/src/components/analytics/ResourceLink.tsx
@@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
import { trackDemoEvent } from "@/lib/analytics";
type ResourceLinkProps = ComponentProps<"a"> & {
- destination: "github" | "documentation";
+ destination: "github" | "documentation" | "npm";
};
export function ResourceLink({ destination, onClick, ...props }: ResourceLinkProps) {
diff --git a/apps/demo/src/components/components/CodeExample.tsx b/apps/demo/src/components/components/CodeExample.tsx
new file mode 100644
index 0000000..f285dfd
--- /dev/null
+++ b/apps/demo/src/components/components/CodeExample.tsx
@@ -0,0 +1,28 @@
+"use client";
+import { useCopyFeedback } from "@/components/playground/useCopyFeedback";
+export function CodeExample({
+ code,
+ label = "React",
+}: {
+ code: string;
+ label?: string;
+}) {
+ const { copy, status, value } = useCopyFeedback();
+ const copied = status === "copied" && value === code;
+ return (
+
+
+ {label}
+ void copy(code)}>
+ {copied ? "Copied" : "Copy code"}
+
+
+
+ {code}
+
+ {status === "failed" && (
+
Select the code and copy it manually.
+ )}
+
+ );
+}
diff --git a/apps/demo/src/components/components/ComponentsExplorer.tsx b/apps/demo/src/components/components/ComponentsExplorer.tsx
new file mode 100644
index 0000000..52e891c
--- /dev/null
+++ b/apps/demo/src/components/components/ComponentsExplorer.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useCallback } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { PlaygroundLoader } from "@/components/playground/PlaygroundLoader";
+import {
+ componentsHref,
+ selectedComponent,
+ type ComponentView,
+} from "@/lib/components-navigation";
+import { GroupExample } from "./GroupExample";
+import { MenuPlayground } from "./MenuPlayground";
+
+const choices: ReadonlyArray<{ id: ComponentView; label: string }> = [
+ { id: "actions", label: "Split actions" },
+ { id: "menu", label: "Morphing menu" },
+ { id: "glass", label: "Liquid Glass" },
+];
+
+export function ComponentsExplorer() {
+ const router = useRouter();
+ const search = useSearchParams();
+ const selected = selectedComponent(search);
+ const choose = useCallback(
+ (component: ComponentView) => {
+ router.push(
+ componentsHref(
+ new URLSearchParams(search.toString()),
+ component,
+ window.location.hash,
+ ),
+ { scroll: false },
+ );
+ },
+ [router, search],
+ );
+ const menuKind = selected === "menu" ? "menu" : "actions";
+ const menuActive = selected !== "glass";
+
+ return (
+ <>
+
+ {choices.map(({ id, label }) => (
+ choose(id)}
+ >
+ {label}
+
+ ))}
+
+
+
+
+ >
+ );
+}
diff --git a/apps/demo/src/components/components/GroupExample.tsx b/apps/demo/src/components/components/GroupExample.tsx
new file mode 100644
index 0000000..cdc1643
--- /dev/null
+++ b/apps/demo/src/components/components/GroupExample.tsx
@@ -0,0 +1,181 @@
+"use client";
+import { useEffect, useRef, useState } from "react";
+import {
+ LiquidGlassGroup,
+ LiquidGlassSurface,
+ type GlassGroupHandle,
+ type GlassSurface,
+} from "react-glaze";
+
+export function GroupExample() {
+ const stage = useRef(null),
+ group = useRef(null);
+ const recoveryTimer = useRef | undefined>(
+ undefined,
+ );
+ const [width, setWidth] = useState(320),
+ [expanded, setExpanded] = useState(false);
+ const [backdrop, setBackdrop] = useState(false),
+ [word, setWord] = useState(false);
+ const [mounted, setMounted] = useState(true),
+ [enabled, setEnabled] = useState(true);
+ const [status, setStatus] = useState("Ready to compose."),
+ [error, setError] = useState("");
+ useEffect(() => {
+ if (!stage.current) return;
+ const observer = new ResizeObserver(([entry]) =>
+ setWidth(Math.max(200, Math.min(680, entry.contentRect.width))),
+ );
+ observer.observe(stage.current);
+ return () => observer.disconnect();
+ }, []);
+ useEffect(() => () => clearTimeout(recoveryTimer.current), []);
+ const testRecovery = () => {
+ const canvas = group.current?.element?.querySelector("canvas");
+ if (!canvas) {
+ setStatus("Bring the group into view before testing graphics recovery.");
+ return;
+ }
+ const context = canvas?.getContext("webgl2");
+ const extension = context?.getExtension("WEBGL_lose_context");
+ if (!extension) {
+ setStatus("This browser does not expose the graphics recovery check.");
+ return;
+ }
+ clearTimeout(recoveryTimer.current);
+ extension.loseContext();
+ setStatus("Testing native fallback and graphics recovery.");
+ recoveryTimer.current = setTimeout(() => {
+ if (canvas?.isConnected) {
+ extension.restoreContext();
+ setStatus("Graphics recovery requested.");
+ }
+ }, 900);
+ };
+ const x = width / 2;
+ const surfaces: GlassSurface[] = [
+ { id: "origin", x, y: 250, width: 72, height: 72 },
+ {
+ id: "first",
+ x: expanded ? x - 74 : x,
+ y: expanded ? 180 : 250,
+ width: expanded ? 56 : 0,
+ height: expanded ? 56 : 0,
+ },
+ {
+ id: "second",
+ x,
+ y: expanded ? 144 : 250,
+ width: expanded ? 56 : 0,
+ height: expanded ? 56 : 0,
+ },
+ {
+ id: "third",
+ x: expanded ? x + 74 : x,
+ y: expanded ? 180 : 250,
+ width: expanded ? 56 : 0,
+ height: expanded ? 56 : 0,
+ },
+ ];
+ return (
+
+
+
COMPOSE YOUR OWN
+
The material is yours.
+
+ Give each surface an identity and a destination. Your content stays
+ native, and the group handles the shared glass and motion.
+
+
+
+
+ YOUR CONTENT, YOUR INTERACTION
+ {word ? "Stay curious." : "Make a move."}
+
+ {mounted && (
+
setError("")}
+ onError={(e) => setError(e.message)}
+ >
+ {
+ group.current?.pulse(
+ "origin",
+ expanded ? "expand" : "compress",
+ );
+ setExpanded(!expanded);
+ }}
+ >
+ {expanded ? "×" : "+"}
+
+ {["first", "second", "third"].map((id, i) => (
+ setStatus(`Surface ${i + 1} selected.`)}
+ >
+ {i + 1}
+
+ ))}
+
+ )}
+
+
+ setBackdrop((v) => !v)}>
+ Change backdrop
+
+ setWord((v) => !v)}>
+ Change text
+
+ setEnabled((v) => !v)}>
+ {enabled ? "Show fallback" : "Show glass"}
+
+ setMounted((v) => !v)}>
+ {mounted ? "Remove group" : "Mount group"}
+
+
+
+ {status}
+
+
+ Native fallback and recovery
+
+ The controls remain usable when graphics are unavailable. This check
+ briefly releases the graphics context, then asks the browser to
+ restore it.
+
+
+ Test graphics recovery
+
+
+ {error && (
+
+ The native controls are available. Glass could not be drawn: {error}
+
+ )}
+
+ );
+}
diff --git a/apps/demo/src/components/components/MenuPlayground.tsx b/apps/demo/src/components/components/MenuPlayground.tsx
new file mode 100644
index 0000000..b5b03a5
--- /dev/null
+++ b/apps/demo/src/components/components/MenuPlayground.tsx
@@ -0,0 +1,461 @@
+"use client";
+import { useMemo, useRef, useState } from "react";
+import Image from "next/image";
+import {
+ LiquidGlassActions,
+ LiquidGlassMenu,
+ type GlassAction,
+ type GlassActionLayout,
+ type GlassDirection,
+ type GlassMenuHandle,
+ type GlassPlaybackState,
+} from "react-glaze";
+import { materialScenes } from "@/lib/material-scenes";
+import { CodeExample } from "./CodeExample";
+import { ActionIcon, actionIconPaths } from "./icons";
+import { MotionPlayback, type MotionPlaybackHandle } from "./MotionPlayback";
+
+const initial = {
+ count: 5,
+ layout: "fan" as GlassActionLayout,
+ direction: "right" as GlassDirection,
+ size: 44,
+ distance: -80,
+ connection: 12,
+ duration: 500,
+ bounce: 0.5,
+ refraction: 0.4,
+ labels: false,
+};
+const choices = [
+ { id: "save", label: "Save" },
+ { id: "favorite", label: "Favorite" },
+ { id: "copy", label: "Copy link" },
+ { id: "reset", label: "Reset" },
+ { id: "info", label: "Details" },
+ { id: "disabled", label: "Unavailable" },
+];
+function Duration({
+ value,
+ onChange,
+}: {
+ value: number;
+ onChange: (value: number) => void;
+}) {
+ const [draft, setDraft] = useState(null);
+ const commit = () => {
+ const text = draft ?? String(value),
+ next = Number(text);
+ const safe =
+ text.trim() && Number.isFinite(next)
+ ? Math.max(100, Math.min(3000, Math.round(next)))
+ : value;
+ setDraft(null);
+ onChange(safe);
+ };
+ return (
+
+ Duration{" "}
+
+ setDraft(e.target.value)}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") e.currentTarget.blur();
+ }}
+ />{" "}
+ ms
+
+
+ );
+}
+export function MenuPlayground({
+ kind,
+ active,
+}: {
+ kind: "actions" | "menu";
+ active: boolean;
+}) {
+ const [config, setConfig] = useState(initial);
+ const view = `${active}:${kind}`;
+ const [previousView, setPreviousView] = useState(view);
+ const [open, setOpen] = useState(false),
+ [scene, setScene] = useState(0),
+ [saved, setSaved] = useState(false),
+ [favorite, setFavorite] = useState(false);
+ const [message, setMessage] = useState("Open the glass to choose an action."),
+ [error, setError] = useState("");
+ const stage = useRef(null);
+ const menu = useRef(null);
+ const playback = useRef(null);
+ const externalControls = useRef(null);
+ if (view !== previousView) {
+ setPreviousView(view);
+ setOpen(false);
+ }
+ const patch = (value: Partial) =>
+ setConfig((c) => ({ ...c, ...value }));
+ const reset = () => {
+ setConfig(initial);
+ setOpen(false);
+ setError("");
+ };
+ const items = useMemo(
+ () =>
+ choices.slice(0, config.count).map((item) => ({
+ ...item,
+ icon: ,
+ disabled: item.id === "disabled",
+ onSelect: () => {
+ if (item.id === "save") {
+ setSaved((v) => !v);
+ setMessage(
+ saved ? "Bookmark removed." : "Bookmarked for this session.",
+ );
+ } else if (item.id === "favorite") {
+ setFavorite((v) => !v);
+ setMessage(
+ favorite ? "Removed from favorites." : "Added to favorites.",
+ );
+ } else if (item.id === "copy") {
+ void navigator.clipboard.writeText(location.href).then(
+ () => setMessage("Link copied."),
+ () => setMessage("Copy the address bar to share this page."),
+ );
+ } else if (item.id === "reset") {
+ setConfig(initial);
+ setMessage("Back to the starting point.");
+ } else
+ setMessage(
+ "These are native buttons. Their behavior belongs to your app.",
+ );
+ },
+ })),
+ [config.count, saved, favorite],
+ );
+ const directions: GlassDirection[] =
+ config.layout === "row"
+ ? ["left", "right"]
+ : config.layout === "column"
+ ? ["top", "bottom"]
+ : ["left", "top", "right", "bottom"];
+ const component =
+ kind === "actions" ? "LiquidGlassActions" : "LiquidGlassMenu";
+ const code = `'use client';\n\nimport { ${component} } from "react-glaze";\n\nexport function PhotoActions() {\n return (\n <${component}\n label="Photo actions"\n items={[\n${choices
+ .slice(0, config.count)
+ .map(
+ (item) =>
+ ` { id: "${item.id}", label: "${item.label}", icon: ,${item.id === "disabled" ? " disabled: true," : ""} onSelect: () => console.log("${item.id}") },`,
+ )
+ .join(
+ "\n",
+ )}\n ]}${kind === "actions" ? `\n layout="${config.layout}"\n direction="${config.direction}"\n actionSize={${config.size}}\n distance={${config.distance}}\n connection={${config.connection}}` : `\n direction="${config.direction}"`}\n appearance="${config.labels ? "labels" : "icons"}"\n motion={{ duration: ${config.duration}, bounce: ${config.bounce} }}\n refraction={${config.refraction}}\n />\n );\n}\n\nfunction Icon({ path }: { path: string }) {\n return \n \n ;\n}`;
+ const common = {
+ label: "Photo actions",
+ items,
+ open,
+ onOpenChange: setOpen,
+ appearance: config.labels ? ("labels" as const) : ("icons" as const),
+ motion: { duration: config.duration, bounce: config.bounce },
+ refraction: config.refraction,
+ direction: config.direction,
+ onError: (e: Error) => setError(e.message),
+ onReady: () => setError(""),
+ ref: menu,
+ externalControls,
+ onPlaybackUpdate: (state: GlassPlaybackState) =>
+ playback.current?.update(state),
+ };
+ if (!active) return null;
+
+ return (
+ {
+ if (open && !event.defaultPrevented && event.key === "Escape") {
+ event.preventDefault();
+ setOpen(false);
+ }
+ }}
+ >
+
+
+
{
+ if (
+ open &&
+ !(event.target instanceof Element && event.target.closest("button"))
+ )
+ setOpen(false);
+ }}
+ >
+ {scene < 3 ? (
+
+ ) : (
+
+ SHAPES OF THINGS
+
+ Always
+
+ in motion.
+
+
+ )}
+
+ {scene === 3
+ ? "04 / TYPE STUDY"
+ : `0${scene + 1} / ${materialScenes[scene].name.toUpperCase()}`}
+
+
+ {kind === "actions" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
{message}
+
setOpen((value) => !value)}
+ >
+ {open ? "Close" : "Open"}
+
+
+
+
+ {error && (
+
+ Native controls are available. Glass could not be drawn: {error}
+
+ )}
+
+
+
+
Make it yours.
+
+ Reset
+
+
+
+ Composition
+
+ Actions
+ patch({ count: Number(e.target.value) })}
+ >
+ {choices.map((_, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+ {kind === "actions" && (
+
+ Layout
+ {
+ const layout = e.target.value as GlassActionLayout;
+ patch({
+ layout,
+ direction: layout === "row" ? "right" : "top",
+ });
+ }}
+ >
+ Fan / around
+ Row
+ Column
+
+
+ )}
+
+ Direction
+
+ patch({ direction: e.target.value as GlassDirection })
+ }
+ >
+ {(kind === "menu"
+ ? ["top", "right", "bottom", "left"]
+ : directions
+ ).map((d) => (
+
+ {d}
+
+ ))}
+
+
+ {kind === "actions" && (
+ <>
+
+
+ Action size {config.size}px
+
+ patch({ size: Number(e.target.value) })}
+ />
+
+
+
+ Trigger distance {config.distance}px
+
+
+ patch({ distance: Number(e.target.value) })
+ }
+ />
+
+
+ Negative moves the group closer. Spacing between actions stays
+ the same.
+
+ >
+ )}
+
+ Show labels
+ patch({ labels: e.target.checked })}
+ />
+
+
+
+ Material and motion
+ patch({ duration })}
+ />
+ {kind === "actions" && (
+ <>
+
+
+ Connection reach {config.connection}px
+
+
+ patch({ connection: Number(e.target.value) })
+ }
+ />
+
+
+ How far nearby glass surfaces reach toward each other.
+
+ >
+ )}
+
+
+ Bounce {Math.round(config.bounce * 100)}%
+
+ patch({ bounce: Number(e.target.value) })}
+ />
+
+
+
+ Refraction{" "}
+ {Math.round(config.refraction * 100)}%
+
+ patch({ refraction: Number(e.target.value) })}
+ />
+
+
+ Duration includes the spring settling. Your system's
+ reduced-motion preference is respected.
+
+
+
+ Behind the glass
+
+ {["Soft forms", "Hard light", "Small world", "Typography"].map(
+ (label, index) => (
+ setScene(index)}
+ style={
+ index < 3
+ ? {
+ backgroundImage: `url(${materialScenes[index].src})`,
+ }
+ : undefined
+ }
+ >
+ {index === 3 ? "Aa" : null}
+
+ ),
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/demo/src/components/components/MotionPlayback.tsx b/apps/demo/src/components/components/MotionPlayback.tsx
new file mode 100644
index 0000000..3244b05
--- /dev/null
+++ b/apps/demo/src/components/components/MotionPlayback.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+ type RefObject,
+} from "react";
+import type {
+ GlassPlaybackControls,
+ GlassPlaybackState,
+} from "react-glaze";
+
+export interface MotionPlaybackHandle {
+ update(state: GlassPlaybackState): void;
+}
+
+const unavailable: GlassPlaybackState = {
+ time: 0,
+ duration: 0,
+ paused: false,
+ playing: false,
+ available: false,
+};
+
+export const MotionPlayback = forwardRef<
+ MotionPlaybackHandle,
+ { controls: RefObject }
+>(function MotionPlayback({ controls }, ref) {
+ const [displayed, setDisplayed] = useState(unavailable);
+ const latest = useRef(unavailable);
+ const timer = useRef | undefined>(undefined);
+ const commit = (state: GlassPlaybackState) => {
+ if (timer.current !== undefined) clearTimeout(timer.current);
+ timer.current = undefined;
+ setDisplayed(state);
+ };
+
+ useImperativeHandle(ref, () => ({
+ update(state) {
+ const previous = latest.current;
+ latest.current = state;
+ const immediate =
+ state.paused ||
+ state.available !== previous.available ||
+ state.paused !== previous.paused ||
+ state.playing !== previous.playing ||
+ state.time === 0 ||
+ state.time === state.duration;
+ if (immediate) commit(state);
+ else if (timer.current === undefined)
+ timer.current = setTimeout(() => commit(latest.current), 75);
+ },
+ }), []);
+
+ useEffect(() => () => {
+ if (timer.current !== undefined) clearTimeout(timer.current);
+ }, []);
+
+ const current = Math.round(displayed.time);
+ const duration = Math.round(displayed.duration);
+ const status = !displayed.available
+ ? "Open the menu to inspect its animation."
+ : displayed.playing
+ ? "Playing the captured transition."
+ : displayed.paused
+ ? "Paused for inspection."
+ : current >= duration
+ ? "Transition complete."
+ : "Ready to play.";
+
+ return (
+ {
+ if (!event.currentTarget.open) controls.current?.play();
+ }}
+ >
+ Animation playback
+
+
+
+ displayed.playing
+ ? controls.current?.pause()
+ : controls.current?.play()
+ }
+ >
+ {displayed.playing ? "Pause" : "Play"}
+
+ controls.current?.replay()}
+ >
+ Replay
+
+
+
+
+ Animation timeline
+ {current} ms / {duration} ms
+
+ controls.current?.seek(Number(event.target.value))}
+ />
+
+
{status}
+
+
+ );
+});
diff --git a/apps/demo/src/components/components/icons.tsx b/apps/demo/src/components/components/icons.tsx
new file mode 100644
index 0000000..8ec889d
--- /dev/null
+++ b/apps/demo/src/components/components/icons.tsx
@@ -0,0 +1,26 @@
+export const actionIconPaths: Record = {
+ save: "M6 3h12v18l-6-4-6 4V3Z",
+ favorite:
+ "M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.6a5.5 5.5 0 0 0 0-7.8Z",
+ copy: "M9 15 15 9M8 16l-1 1a4.2 4.2 0 0 1-6-6l4-4a4.2 4.2 0 0 1 6 0m2 10a4.2 4.2 0 0 0 6 0l4-4a4.2 4.2 0 0 0-6-6l-1 1",
+ reset: "M3 10a9 9 0 1 1 2 8M3 4v6h6",
+ info: "M12 11v6M12 7h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0",
+ disabled: "M5 11h14v10H5V11Zm3 0V7a4 4 0 0 1 8 0v4",
+};
+export function ActionIcon({ name }: { name: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/demo/src/components/playground/App.tsx b/apps/demo/src/components/playground/App.tsx
index 1cd57d6..19f927f 100644
--- a/apps/demo/src/components/playground/App.tsx
+++ b/apps/demo/src/components/playground/App.tsx
@@ -1,8 +1,6 @@
import { ControlMaterial } from "./ControlMaterial";
import { CopyButton } from "./CopyButton";
import { useEffect, useRef, useState } from "react";
-import Link from "next/link";
-import { BrandIcon } from "@/components/brand/BrandIcon";
import { PRESETS } from "react-glaze";
import type { GlassPreset } from "react-glaze";
import { backgrounds, initialState, shareableState } from "./config";
@@ -16,7 +14,7 @@ import { useCopyFeedback } from "./useCopyFeedback";
import { GlassPreview } from "./GlassPreview";
import { CodePanel } from "./CodePanel";
-export function App() {
+export function App({ active }: { active: boolean }) {
const [state, setState] = useState(readPlaygroundState);
const [previewRevision, setPreviewRevision] = useState(0);
const custom = useCustomBackground();
@@ -46,44 +44,33 @@ export function App() {
},
}));
const shareUrl = new URL(location.href);
+ shareUrl.searchParams.set("component", "glass");
shareUrl.searchParams.set("config", JSON.stringify(shareableState(state)));
const shareHref = shareUrl.href;
useEffect(() => {
- history.replaceState(null, "", shareHref);
- }, [shareHref]);
+ if (!active || location.href === shareHref) return;
+ history.replaceState(history.state, "", shareHref);
+ }, [active, shareHref]);
const shuffle = () => {
const choices = backgrounds.filter((b) => b.id !== state.background);
const value = new Uint32Array(1);
crypto.getRandomValues(value);
chooseBackground(choices[value[0] % choices.length].id);
};
+
+ if (!active) return null;
+
return (
-
-
- Skip to playground
-
-
-
+
+
-
A different feeling. Your own.
+
Liquid Glass editor
Choose a scene. Shape the glass. Take it with you.
@@ -162,25 +149,8 @@ export function App() {
-
-
-
+
+
);
}
diff --git a/apps/demo/src/components/playground/PlaygroundLoader.tsx b/apps/demo/src/components/playground/PlaygroundLoader.tsx
index c36bf79..4f4475d 100644
--- a/apps/demo/src/components/playground/PlaygroundLoader.tsx
+++ b/apps/demo/src/components/playground/PlaygroundLoader.tsx
@@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
+import { useState } from "react";
const Playground = dynamic(() => import("./App").then((module) => module.App), {
ssr: false,
@@ -13,6 +14,9 @@ const Playground = dynamic(() => import("./App").then((module) => module.App), {
),
});
-export function PlaygroundLoader() {
- return ;
+export function PlaygroundLoader({ active }: { active: boolean }) {
+ const [hasMounted, setHasMounted] = useState(active);
+ if (active && !hasMounted) setHasMounted(true);
+
+ return hasMounted || active ? : null;
}
diff --git a/apps/demo/src/lib/analytics.ts b/apps/demo/src/lib/analytics.ts
index 0cbfa28..a556af8 100644
--- a/apps/demo/src/lib/analytics.ts
+++ b/apps/demo/src/lib/analytics.ts
@@ -15,7 +15,7 @@ type DemoEvent =
| { name: "playground_code_copied"; properties: { format: "jsx" | "json" } }
| {
name: "resource_link_clicked";
- properties: { destination: "github" | "documentation" };
+ properties: { destination: "github" | "documentation" | "npm" };
};
export function trackDemoEvent(event: DemoEvent) {
diff --git a/apps/demo/src/lib/components-navigation.ts b/apps/demo/src/lib/components-navigation.ts
new file mode 100644
index 0000000..781d4e8
--- /dev/null
+++ b/apps/demo/src/lib/components-navigation.ts
@@ -0,0 +1,40 @@
+export const componentViews = ["actions", "menu", "glass"] as const;
+
+export type ComponentView = (typeof componentViews)[number];
+
+export type RouteSearchParams = Record;
+
+export function isComponentView(value: string | null): value is ComponentView {
+ return value !== null && componentViews.includes(value as ComponentView);
+}
+
+export function selectedComponent(search: Pick): ComponentView {
+ const requested = search.get("component");
+ if (isComponentView(requested)) return requested;
+ return search.has("config") ? "glass" : "actions";
+}
+
+export function componentsHref(
+ search: URLSearchParams,
+ component: ComponentView,
+ hash = "",
+): string {
+ const next = new URLSearchParams(search);
+ next.set("component", component);
+ return `/components?${next.toString()}${hash}`;
+}
+
+export function redirectToComponents(
+ search: RouteSearchParams,
+ component: ComponentView = "glass",
+): string {
+ const next = new URLSearchParams();
+ for (const [key, value] of Object.entries(search)) {
+ if (Array.isArray(value)) {
+ for (const item of value) next.append(key, item);
+ } else if (value !== undefined) {
+ next.append(key, value);
+ }
+ }
+ return componentsHref(next, component);
+}
diff --git a/apps/demo/src/lib/design-system/components.css b/apps/demo/src/lib/design-system/components.css
new file mode 100644
index 0000000..b0ed56c
--- /dev/null
+++ b/apps/demo/src/lib/design-system/components.css
@@ -0,0 +1,488 @@
+.components-page {
+ max-width: var(--width-wide);
+ margin-inline: auto;
+ padding: var(--space-6) var(--space-7);
+ color: hsl(var(--foreground));
+}
+.components-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-5);
+}
+.components-header nav {
+ display: flex;
+ gap: var(--space-5);
+ font-size: 0.85rem;
+ color: hsl(var(--muted-foreground));
+}
+.components-header nav [aria-current] {
+ color: hsl(var(--foreground));
+}
+.components-intro {
+ padding-block: var(--space-8);
+ display: grid;
+ grid-template-columns: 1fr auto;
+ column-gap: var(--space-7);
+ align-items: end;
+}
+.components-eyebrow {
+ font-size: 0.65rem;
+ letter-spacing: var(--tracking-label);
+ color: hsl(var(--muted-foreground));
+ grid-column: 1/-1;
+ margin-block: 0 var(--space-4);
+}
+.components-intro h1 {
+ font-size: clamp(2.8rem, 6vw, 5.3rem);
+ line-height: 1.02;
+ letter-spacing: -0.06em;
+ margin: 0;
+ font-weight: 600;
+}
+.components-intro h1 span {
+ color: hsl(var(--muted-foreground));
+}
+.components-intro > p:last-child {
+ font-size: 1rem;
+ line-height: var(--leading-body);
+ color: hsl(var(--muted-foreground));
+ margin: 0 0 var(--space-1);
+}
+.components-section-copy {
+ max-width: var(--width-prose);
+ margin-bottom: var(--space-6);
+}
+.components-section-copy h2 {
+ font-size: clamp(1.6rem, 3vw, 2.5rem);
+ letter-spacing: var(--tracking-display);
+ margin: 0 0 var(--space-4);
+ font-weight: 550;
+}
+.components-section-copy p:last-child {
+ color: hsl(var(--muted-foreground));
+ line-height: var(--leading-body);
+ max-width: 52ch;
+}
+.components-core-stage {
+ position: relative;
+ min-height: 360px;
+ display: flex;
+ justify-content: center;
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background: hsl(var(--accent));
+ color: hsl(var(--accent-foreground));
+ background-image:
+ linear-gradient(90deg, hsl(var(--border) / 0.6) 1px, transparent 1px),
+ linear-gradient(hsl(var(--border) / 0.6) 1px, transparent 1px);
+ background-size: 32px 32px;
+}
+.components-core-stage.alternate {
+ background-color: hsl(var(--primary));
+ color: hsl(var(--primary-foreground));
+ background-size: 16px 16px;
+}
+.components-core-copy {
+ position: absolute;
+ inset: var(--space-7) var(--space-5);
+ pointer-events: none;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+}
+.components-core-copy span {
+ font-size: 0.6rem;
+ letter-spacing: var(--tracking-label);
+}
+.components-core-copy strong {
+ font-size: clamp(2.6rem, 6vw, 5rem);
+ letter-spacing: -0.065em;
+ line-height: 1.05;
+ max-width: 7ch;
+}
+.components-core-stage [data-glaze-content] {
+ font-size: 1.35rem;
+ font-weight: 500;
+}
+.components-example-tools {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+ margin-top: var(--space-5);
+}
+.components-example-tools button,
+.components-code button {
+ background: hsl(var(--accent));
+ border: var(--border-width) solid hsl(var(--border));
+ color: inherit;
+ border-radius: var(--radius-md);
+ padding: var(--space-2) var(--space-3);
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+.components-example-status,
+.components-error {
+ font-size: 0.8rem;
+ line-height: var(--leading-body);
+ color: hsl(var(--muted-foreground));
+ margin-block: var(--space-4);
+}
+.components-error {
+ color: hsl(var(--destructive));
+}
+.components-footer {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--space-4);
+ border-top: var(--border-width) solid hsl(var(--border));
+ padding-top: var(--space-5);
+ margin-top: var(--space-8);
+ font-size: 0.75rem;
+ color: hsl(var(--muted-foreground));
+}
+@media (max-width: 48rem) {
+ .components-page {
+ padding: var(--space-5) var(--space-4);
+ }
+ .components-header {
+ flex-wrap: wrap;
+ }
+ .components-header nav {
+ gap: var(--space-4);
+ }
+ .components-intro {
+ grid-template-columns: 1fr;
+ gap: var(--space-5);
+ padding-block: var(--space-7);
+ }
+ .components-core-copy {
+ inset: var(--space-6) var(--space-4);
+ }
+}
+.components-runtime {
+ margin-block: var(--space-5);
+ font-size: 0.8rem;
+ color: hsl(var(--muted-foreground));
+ max-width: var(--width-prose);
+}
+.components-runtime summary {
+ cursor: pointer;
+ color: hsl(var(--foreground));
+}
+.components-runtime p {
+ line-height: var(--leading-body);
+}
+.components-runtime button {
+ border: var(--border-width) solid hsl(var(--border));
+ background: hsl(var(--accent));
+ color: hsl(var(--foreground));
+ border-radius: var(--radius-md);
+ padding: var(--space-2) var(--space-3);
+ cursor: pointer;
+}
+.components-playground {
+ margin-bottom: var(--space-8);
+}
+.components-tabs {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ align-items: center;
+ gap: var(--space-2);
+ border-top: var(--border-width) solid hsl(var(--border));
+ padding-top: var(--space-4);
+ margin-bottom: var(--space-5);
+}
+.components-tabs button {
+ border: 0;
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-4);
+ background: transparent;
+ color: hsl(var(--muted-foreground));
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+.components-tabs button[aria-pressed="true"] {
+ background: hsl(var(--accent));
+ color: hsl(var(--foreground));
+}
+.components-editor {
+ min-width: 0;
+ min-height: 0;
+ padding-inline: 0;
+ background: transparent;
+}
+.components-editor .workspace {
+ max-width: none;
+}
+.components-workspace {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 260px;
+ gap: var(--space-6);
+}
+.components-material-stage {
+ position: relative;
+ height: 520px;
+ border-radius: var(--radius-lg);
+ background: hsl(var(--accent));
+ color: white;
+}
+.components-art {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ border-radius: var(--radius-lg);
+}
+.components-menu-anchor {
+ position: absolute;
+ left: 42%;
+ top: 52%;
+ transform: translate(-50%, -50%);
+}
+.components-scene-label {
+ position: absolute;
+ left: var(--space-4);
+ top: var(--space-4);
+ font-size: 0.6rem;
+ letter-spacing: var(--tracking-label);
+ text-shadow: 0 1px 8px #0008;
+}
+.components-material-stage.type-study {
+ color: hsl(var(--accent-foreground));
+ background-image:
+ linear-gradient(90deg, hsl(var(--border) / 0.6) 1px, transparent 1px),
+ linear-gradient(hsl(var(--border) / 0.6) 1px, transparent 1px);
+ background-size: 40px 40px;
+}
+.components-type-copy {
+ position: absolute;
+ inset: var(--space-8) var(--space-6);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-7);
+}
+.components-type-copy span {
+ font-size: 0.7rem;
+ letter-spacing: var(--tracking-label);
+}
+.components-type-copy strong {
+ font-size: clamp(3rem, 7vw, 6rem);
+ line-height: 1;
+ letter-spacing: -0.06em;
+}
+.components-preview-caption {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-4);
+ padding-block: var(--space-3);
+ font-size: 0.75rem;
+ color: hsl(var(--muted-foreground));
+}
+.components-preview-caption button {
+ border: 0;
+ background: hsl(var(--accent));
+ border-radius: var(--radius-md);
+ color: hsl(var(--foreground));
+ padding: var(--space-2) var(--space-4);
+ cursor: pointer;
+}
+.components-playback {
+ border-top: var(--border-width) solid hsl(var(--border));
+ color: hsl(var(--foreground));
+ font-size: 0.75rem;
+}
+.components-playback summary {
+ padding-block: var(--space-3);
+ color: hsl(var(--muted-foreground));
+ cursor: pointer;
+}
+.components-playback-body {
+ display: grid;
+ gap: var(--space-3);
+ padding-bottom: var(--space-4);
+}
+.components-playback-actions {
+ display: flex;
+ gap: var(--space-2);
+}
+.components-playback-actions button {
+ border: var(--border-width) solid hsl(var(--border));
+ border-radius: var(--radius-md);
+ padding: var(--space-2) var(--space-4);
+ background: hsl(var(--accent));
+ color: hsl(var(--foreground));
+ cursor: pointer;
+}
+.components-playback-actions button:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+.components-playback .components-range {
+ margin-bottom: 0;
+}
+.components-playback-status {
+ margin: 0;
+ color: hsl(var(--muted-foreground));
+ font-size: 0.65rem;
+}
+.components-controls-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--space-5);
+}
+.components-controls h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ letter-spacing: var(--tracking-display);
+ margin: 0;
+}
+.components-controls-heading button {
+ background: none;
+ border: 0;
+ color: hsl(var(--muted-foreground));
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+.components-controls fieldset {
+ border: 0;
+ border-bottom: var(--border-width) solid hsl(var(--border));
+ padding: 0 0 var(--space-5);
+ margin: 0 0 var(--space-5);
+ min-width: 0;
+}
+.components-controls legend {
+ font-size: 0.65rem;
+ letter-spacing: var(--tracking-label);
+ color: hsl(var(--muted-foreground));
+ margin-bottom: var(--space-4);
+}
+.components-control-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ font-size: 0.75rem;
+ margin-bottom: var(--space-4);
+}
+.components-controls select,
+.components-controls input[type="number"] {
+ max-width: 115px;
+ border: var(--border-width) solid hsl(var(--border));
+ border-radius: var(--radius-md);
+ background: hsl(var(--accent));
+ color: inherit;
+ padding: var(--space-2);
+ font-size: 0.75rem;
+}
+.components-controls input[type="number"] {
+ width: 76px;
+ text-align: right;
+}
+.components-range {
+ display: block;
+ font-size: 0.75rem;
+ margin-bottom: var(--space-4);
+}
+.components-range > span {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: var(--space-2);
+}
+.components-range output,
+.components-control-note {
+ color: hsl(var(--muted-foreground));
+}
+.components-range input {
+ width: 100%;
+ accent-color: hsl(var(--foreground));
+}
+.components-control-note {
+ font-size: 0.65rem;
+ line-height: var(--leading-body);
+ margin-block: var(--space-2) var(--space-4);
+}
+.components-controls input[type="checkbox"] {
+ accent-color: hsl(var(--foreground));
+}
+.components-backdrops {
+ display: flex;
+ gap: var(--space-2);
+}
+.components-backdrops button {
+ width: 44px;
+ height: 44px;
+ background-size: cover;
+ border: 2px solid transparent;
+ background-color: hsl(var(--accent));
+ color: hsl(var(--foreground));
+ border-radius: var(--radius-md);
+ cursor: pointer;
+}
+.components-backdrops button[aria-pressed="true"] {
+ outline: 2px solid hsl(var(--foreground));
+ outline-offset: 2px;
+}
+.components-code {
+ border: var(--border-width) solid hsl(var(--border));
+ border-radius: var(--radius-lg);
+ background: hsl(var(--card));
+ margin-top: var(--space-5);
+ overflow: hidden;
+}
+.components-code > div {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: var(--border-width) solid hsl(var(--border));
+ font-size: 0.7rem;
+ color: hsl(var(--muted-foreground));
+}
+.components-code pre {
+ margin: 0;
+ padding: var(--space-5);
+ overflow: auto;
+ font-size: 0.75rem;
+ line-height: 1.7;
+ color: hsl(var(--card-foreground));
+ tab-size: 2;
+}
+@media (max-width: 64rem) {
+ .components-workspace {
+ grid-template-columns: 1fr;
+ }
+ .components-controls {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-5);
+ }
+ .components-controls-heading {
+ grid-column: 1/-1;
+ margin-bottom: 0;
+ }
+ .components-material-stage {
+ height: 480px;
+ }
+ .components-controls fieldset:last-child {
+ grid-column: 1/-1;
+ }
+}
+@media (max-width: 30rem) {
+ .components-tabs button {
+ padding: var(--space-3);
+ font-size: 0.8rem;
+ }
+ .components-controls {
+ gap: var(--space-4);
+ }
+ .components-menu-anchor {
+ left: 42%;
+ }
+ .components-code pre {
+ padding: var(--space-4);
+ font-size: 0.65rem;
+ }
+}
diff --git a/apps/demo/src/lib/design-system/index.css b/apps/demo/src/lib/design-system/index.css
index c6423a0..161c38d 100644
--- a/apps/demo/src/lib/design-system/index.css
+++ b/apps/demo/src/lib/design-system/index.css
@@ -19,3 +19,5 @@
@import "./playground.css";
@import "./showcase.css";
@import "./landing.css";
+
+@import "./components.css";
diff --git a/apps/demo/src/lib/design-system/landing.css b/apps/demo/src/lib/design-system/landing.css
index 2078e18..dfc9f07 100644
--- a/apps/demo/src/lib/design-system/landing.css
+++ b/apps/demo/src/lib/design-system/landing.css
@@ -49,7 +49,12 @@
height: calc(var(--space-5) + var(--space-4));
flex: none;
}
-.landing-github {
+.landing-resources {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+.landing-resource-link {
display: inline-flex;
align-items: center;
gap: var(--space-4);
@@ -218,7 +223,7 @@
.landing-footer > div { display: flex; align-items: center; gap: var(--space-3); }
@media (hover: hover) and (pointer: fine) {
.landing-route-link:hover > span:first-child { transform: translateX(calc(var(--enter-distance) / 6)); text-decoration-color: currentColor; }
- .landing-github:hover { text-decoration: underline; }
+ .landing-resource-link:hover { text-decoration: underline; }
.install-command-button:hover { background: hsl(var(--accent)); }
.landing-scene-button:hover:not([aria-pressed="true"]) { background: hsl(var(--accent)); color: hsl(var(--foreground)); }
}
diff --git a/apps/demo/src/lib/design-system/playground/responsive.css b/apps/demo/src/lib/design-system/playground/responsive.css
index 9f284bb..5642c3c 100644
--- a/apps/demo/src/lib/design-system/playground/responsive.css
+++ b/apps/demo/src/lib/design-system/playground/responsive.css
@@ -90,7 +90,7 @@
flex-direction: column;
gap: var(--space-4);
}
- .playground-shell .workspace-intro h1 {
+ .playground-shell .workspace-intro :is(h1, h2) {
font-size: 1.65rem;
}
.playground-shell .workspace-intro p {
diff --git a/apps/demo/src/lib/design-system/playground/workspace.css b/apps/demo/src/lib/design-system/playground/workspace.css
index d4d5410..d4dcc3e 100644
--- a/apps/demo/src/lib/design-system/playground/workspace.css
+++ b/apps/demo/src/lib/design-system/playground/workspace.css
@@ -9,7 +9,7 @@
gap: var(--space-5);
padding-block: var(--space-5) var(--space-6);
}
-.playground-shell .workspace-intro h1 {
+.playground-shell .workspace-intro :is(h1, h2) {
font-size: clamp(1.55rem, 2.3vw, 2.1rem);
font-weight: 500;
line-height: 1.18;
diff --git a/apps/demo/src/lib/seo.ts b/apps/demo/src/lib/seo.ts
index 265c8da..1083dc0 100644
--- a/apps/demo/src/lib/seo.ts
+++ b/apps/demo/src/lib/seo.ts
@@ -5,19 +5,18 @@ export const SITE_NAME = "React Glaze";
export const REPOSITORY_URL = "https://github.com/Stianlars1/react-glaze";
export const PACKAGE_URL = "https://www.npmjs.com/package/react-glaze";
-export const PUBLIC_ROUTES = ["/", "/playground", "/showcase"] as const;
+export const PUBLIC_ROUTES = ["/", "/showcase", "/components"] as const;
type PublicRoute = (typeof PUBLIC_ROUTES)[number];
const pages = {
"/": {
title: "React Glaze - Liquid glass for React",
description:
- "A configurable liquid glass wrapper for React 19 and Next.js. Keep your content and CSS, tune the material in the playground, and explore a working showcase.",
+ "Liquid glass for React 19 and Next.js. Build with a configurable wrapper, morphing menus, spring-driven actions and composable surfaces.",
},
- "/playground": {
- title: "Playground",
- description:
- "Tune React Glaze materials, shapes and rim lighting over different backgrounds. Try your own image, save a configuration, and copy the React code.",
+ "/components": {
+ title: "Components",
+ description: "Explore React Glaze split actions, morphing menus and the Liquid Glass editor. Customize material, shape and rim lighting, then copy the React code.",
},
"/showcase": {
title: "Showcase - Roam",
diff --git a/apps/demo/tests/analytics.test.mjs b/apps/demo/tests/analytics.test.mjs
index 49f8d3d..b57e6d1 100644
--- a/apps/demo/tests/analytics.test.mjs
+++ b/apps/demo/tests/analytics.test.mjs
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { redactAnalyticsUrl } from "../src/lib/analytics.ts";
-test("pageviews and custom events exclude playground configuration and URL fragments", () => {
+test("pageviews and custom events exclude editor configuration and URL fragments", () => {
for (const type of ["pageview", "event"]) {
const event = {
type,
@@ -14,10 +14,19 @@ test("pageviews and custom events exclude playground configuration and URL fragm
});
assert.match(event.url, /private/);
}
+
+ const componentEvent = {
+ type: "pageview",
+ url: "https://react-glaze.app/components?component=glass&config=%7B%22background%22%3A%22private%22%7D#code",
+ };
+ assert.deepEqual(redactAnalyticsUrl(componentEvent), {
+ type: "pageview",
+ url: "https://react-glaze.app/components?component=glass",
+ });
});
test("ordinary route URLs are preserved", () => {
- for (const path of ["/", "/playground", "/showcase"]) {
+ for (const path of ["/", "/components", "/showcase"]) {
const event = { type: "pageview", url: `https://react-glaze.app${path}` };
assert.deepEqual(redactAnalyticsUrl(event), event);
}
diff --git a/apps/demo/tests/components-navigation.test.mjs b/apps/demo/tests/components-navigation.test.mjs
new file mode 100644
index 0000000..21205d5
--- /dev/null
+++ b/apps/demo/tests/components-navigation.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ componentsHref,
+ redirectToComponents,
+ selectedComponent,
+} from "../src/lib/components-navigation.ts";
+import { initialState, shareableState } from "../src/components/playground/config.ts";
+
+test("explicit component choices take precedence over a retained editor configuration", () => {
+ const search = new URLSearchParams({
+ component: "menu",
+ config: JSON.stringify(shareableState(initialState)),
+ });
+
+ assert.equal(selectedComponent(search), "menu");
+ assert.equal(selectedComponent(new URLSearchParams("config=%7B%7D")), "glass");
+});
+
+test("invalid and missing component choices fall back without dropping a configuration", () => {
+ assert.equal(selectedComponent(new URLSearchParams("component=unknown")), "actions");
+ assert.equal(
+ selectedComponent(new URLSearchParams("component=unknown&config=%7B%7D")),
+ "glass",
+ );
+});
+
+test("legacy redirects preserve repeated query values while selecting the editor", () => {
+ const href = redirectToComponents({
+ config: ["first", "second"],
+ source: ["newsletter", "partner"],
+ });
+ const target = new URL(href, "https://react-glaze.app");
+
+ assert.equal(target.pathname, "/components");
+ assert.equal(target.searchParams.get("component"), "glass");
+ assert.deepEqual(target.searchParams.getAll("config"), ["first", "second"]);
+ assert.deepEqual(target.searchParams.getAll("source"), ["newsletter", "partner"]);
+});
+
+test("shared editor links select Liquid Glass and omit uploaded image state", () => {
+ const state = { ...initialState, background: "custom" };
+ const href = componentsHref(
+ new URLSearchParams({ config: JSON.stringify(shareableState(state)) }),
+ "glass",
+ );
+ const target = new URL(href, "https://react-glaze.app");
+
+ assert.equal(target.searchParams.get("component"), "glass");
+ assert.equal(
+ JSON.parse(target.searchParams.get("config")).background,
+ "spectrum",
+ );
+});
diff --git a/apps/demo/tests/seo.test.mjs b/apps/demo/tests/seo.test.mjs
index 7505d24..bae51ca 100644
--- a/apps/demo/tests/seo.test.mjs
+++ b/apps/demo/tests/seo.test.mjs
@@ -3,14 +3,14 @@ import test from "node:test";
import { pageMetadata, serializeJsonLd, siteMetadata, siteStructuredData } from "../src/lib/seo.ts";
import sitemap from "../src/app/sitemap.ts";
-test("route canonicals and the sitemap agree on the three query-free production URLs", () => {
+test("route canonicals and the sitemap agree on all query-free production URLs", () => {
assert.equal(siteMetadata.alternates, undefined);
- const routes = ["/", "/playground", "/showcase"];
+ const routes = ["/", "/showcase", "/components"];
const urls = routes.map((route) => pageMetadata(route).alternates.canonical);
assert.deepEqual(urls, [
"https://react-glaze.app",
- "https://react-glaze.app/playground",
"https://react-glaze.app/showcase",
+ "https://react-glaze.app/components",
]);
assert.deepEqual(sitemap().map(({ url }) => url), urls);
for (const route of routes) {
@@ -19,8 +19,8 @@ test("route canonicals and the sitemap agree on the three query-free production
});
test("child titles receive one brand suffix and identify the relevant demo", () => {
- assert.equal(pageMetadata("/playground").title, "Playground");
- assert.equal(pageMetadata("/playground").openGraph.title, "Playground | React Glaze");
+ assert.equal(pageMetadata("/components").title, "Components");
+ assert.equal(pageMetadata("/components").openGraph.title, "Components | React Glaze");
assert.equal(pageMetadata("/showcase").title, "Showcase - Roam");
assert.equal(pageMetadata("/showcase").twitter.title, "Showcase - Roam | React Glaze");
});
@@ -36,7 +36,7 @@ test("child metadata preserves native file image URLs, dimensions and alt text f
images: [{ url: "https://react-glaze.app/twitter-image.png?build-hash", alt: "React Glaze" }],
},
};
- for (const route of ["/playground", "/showcase"]) {
+ for (const route of ["/showcase", "/components"]) {
const metadata = pageMetadata(route, parent);
assert.deepEqual(metadata.openGraph.images, parent.openGraph.images);
assert.deepEqual(metadata.twitter.images, parent.twitter.images);
diff --git a/docs/adoption.md b/docs/adoption.md
index 653a404..c95c006 100644
--- a/docs/adoption.md
+++ b/docs/adoption.md
@@ -1,4 +1,4 @@
-# Use LiquidGlass in another application
+# Use React Glaze in another application
React Glaze is an experimental React 19 library licensed under MIT. The owner-selected product name is React Glaze and its npm package name is `react-glaze`. No background image prop, global provider or CSS import is required. The [package contract](../packages/react/README.md) documents the desktop Chrome/Firefox/Safari targets and experimental iPhone scope.
@@ -19,11 +19,15 @@ pnpm pack:local
The command builds the package and prints a new archive path under `work/package-packs/`. Each run gets a separate directory. In your own React 19 or Next.js application:
```sh
-npm install /absolute/path/to/react-glaze-0.1.0.tgz
+npm install /absolute/path/to/react-glaze-0.2.0.tgz
```
The installed library must be a real package directory, not a workspace symlink. Do not import from this workspace's source or copy its engine into the application. For a new archive with the same local version, install its new absolute path and restart the consuming app.
+## Choose a component
+
+Use `LiquidGlass` to wrap existing content, `LiquidGlassActions` for separate action buttons, or `LiquidGlassMenu` for a morphing panel. Compose custom geometry with `LiquidGlassGroup` and `LiquidGlassSurface`. The [morph component guide](components.md) covers their complete API, keyboard behavior, fitting, motion and background limits.
+
## Start with ordinary HTML and CSS
In a Next.js App Router page, a server component can render the library's client boundary directly:
@@ -116,4 +120,4 @@ The tested showcase covers native page scrolling, fixed navigation, image change
Video/canvas frames, foreign iframes, cross-origin images without CORS, complex masks/blending, nested scrolling, transformed ancestors, closed shadow DOM, CSSOM/WAAPI-only changes and recursive glass still need separate work. Changing bytes at an unchanged image URL is not automatically observable. Root selection occurs at mount; reparenting requires a remount.
-The runnable examples are the actual [showcase](../apps/demo/README.md) and [playground](../README.md). Public publication and deployment are separate from successful local use.
+The runnable examples are the actual [showcase](../apps/demo/README.md), [components guide](../README.md) and Liquid Glass editor at `/components?component=glass`. Public publication and deployment are separate from successful local use.
diff --git a/docs/components.md b/docs/components.md
new file mode 100644
index 0000000..b46101f
--- /dev/null
+++ b/docs/components.md
@@ -0,0 +1,166 @@
+# Morph components
+
+React Glaze provides a configurable wrapper, composable glass surfaces, split actions and a morphing menu. All imports come from `react-glaze`; there is no global provider or stylesheet to install. Use React 19 and put event handlers in a client component when using Next.js.
+
+The interactive guide is at `/components`: split actions are the default selection, with `component=menu` for the morphing menu and `component=glass` for the Liquid Glass editor. `/playground` redirects to the editor for compatibility.
+
+## Choose a component
+
+| Component | Responsibility |
+| ----------------------------------------- | ------------------------------------------------------------------------------------- |
+| `LiquidGlass` | Wrap ordinary HTML with configurable glass. Existing layout, shape and rim-light API. |
+| `LiquidGlassActions` | A trigger opens separate native action buttons in a row, column or fan. |
+| `LiquidGlassMenu` | One trigger surface becomes a panel of native menu items. |
+| `LiquidGlassGroup` + `LiquidGlassSurface` | Build a custom interaction from identified surfaces and target geometry. |
+
+## Split actions
+
+```tsx
+"use client";
+import { LiquidGlassActions } from "react-glaze";
+
+export function Actions() {
+ return (
+ console.log("Save") },
+ { id: "share", label: "Share", onSelect: () => console.log("Share") },
+ { id: "export", label: "Export", disabled: true },
+ ]}
+ appearance="labels"
+ layout="fan"
+ direction="right"
+ actionSize={56}
+ distance={-40}
+ motion={{ duration: 500, bounce: 0.5 }}
+ refraction={1.5}
+ />
+ );
+}
+```
+
+`items` contains at most six actions. Every action has a stable, unique nonempty `id` and a nonempty accessible `label`. Add `icon` for an icon element; in `appearance="icons"`, items without an icon still show their label. `appearance="labels"` shows icon and label. `content` supplies custom visible content while `label` continues to name the button. Content must be presentational: do not nest links, buttons, inputs or other controls in these slots. The application owns `onSelect`; the library does not save data, copy text or perform navigation itself.
+
+Use `trigger` for custom presentational trigger content. The default split trigger changes from dots to a close icon while open. `className`/`style` style the root, and `triggerClassName`/`itemClassName` style the native controls. Typography and foreground color inherit from the application. Keep sufficient foreground contrast over your backgrounds and test the native fallback too.
+
+`layout` accepts `row`, `column` and `fan`. `direction` accepts `left`, `top`, `right` and `bottom`. A row uses the horizontal axis and a column the vertical axis; fitting can adapt when available space requires it. `distance` is a rigid offset of the whole action group, from -160 to +120 CSS pixels. Zero is the recommended placement. Negative values bring the group closer without compressing its arc or changing distances between actions. The implementation limits movement before trigger hit regions overlap. `actionSize` is the action diameter/height, from 44 to 100px; real labels or custom content can widen the buttons. `triggerSize` is independently configurable from 44 to 100px, default 72.
+
+The trigger keeps its normal-flow anchor. Fitting considers its actual viewport position, flips or wraps when possible, and can use a scrolling panel in tight space. No actions are silently discarded. An empty list disables the trigger. More than six actions or invalid IDs/labels are configuration errors.
+
+## A morphing panel
+
+Use `LiquidGlassMenu` with the same item, material and motion options:
+
+```tsx
+
+```
+
+The trigger's glass becomes one panel. Its native trigger remains anchored but is hidden and inert while the panel is open. Use the close control, Escape, Tab or outside dismissal to return. The panel can scroll when its content is larger than the available space.
+
+## State and keyboard behavior
+
+Both components support `defaultOpen` for local state, or `open` plus `onOpenChange` for application-owned state. A controlled parent decides whether a requested change takes effect. `disabled` disables the trigger; item-level `disabled` keeps an action visible without allowing selection.
+
+Enter/Space activate native buttons. Trigger Up/Down opens at the last/first enabled item. Panel Up/Down cycles enabled items; split arrows follow visible positions. Home/End choose the first/last enabled item, and printable characters search labels. Escape closes and returns focus to the trigger. Tab/Shift+Tab leave the menu in page order; outside dismissal preserves outside focus. Hidden and closing actions leave the keyboard order immediately, while their visual exit can finish.
+
+Menus render in place. Ancestor `overflow`, clipping and stacking still apply; this release does not create a portal or top-layer popover. Keep the control outside clipping ancestors when it must extend beyond them, or clip only the background artwork. Test unusually constrained layouts, custom content, shadow-root focus boundaries and assistive technology in your application.
+
+Controls outside the menu root normally dismiss an open menu on pointer or focus entry. If a bounded toolbar, caption or inspector operates the menu, pass its ref through `externalControls`. Pointer and focus events within that one element are then treated as associated menu interactions. Tab can move from the menu into those controls without dismissing it; moving to unrelated page controls keeps the normal close policy. Do not pass a document-wide element.
+
+Menus fit against the visual viewport by default. To contain actions within a preview stage, pass a positive-size stage ref as `boundary`. The menu measures that stage relative to its trigger, so a shared page scroll does not change the fitting input. The boundary is only for collision fitting; it does not change clipping, stacking or outside-dismissal behavior.
+
+```tsx
+const stage = useRef(null);
+
+
+
+
+```
+
+```tsx
+const menu = useRef(null);
+const controls = useRef(null);
+
+return (
+ <>
+
+
+ menu.current?.replay()}>Replay
+
+ >
+);
+```
+
+## Material and motion
+
+The group-based components share:
+
+| Prop | Meaning |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `motion.duration` | Total timeline duration in milliseconds, including settling. 100-3000, default 500. A new target starts a new interval from current position and velocity. |
+| `motion.bounce` | Independent overshoot, 0-1, default 0.5. |
+| `connection` | Smoothing reach between nearby surfaces in CSS pixels, 0-48, default 12. |
+| `refraction` | Multiplier of the material's thickness, 0-3, default 1. Zero removes background bending while retaining lighting. |
+| `material` | Existing optical material fields, plus preset, enabled, optics, lighting, depth, exposure, shadowOpacity and maxDpr. Default starts from the landing reference material with responsive lighting. |
+| `reducedMotion` | Forces reduced motion when true. The system preference is always respected. |
+| `backdrop` | Optional ref to an explicit background element. Otherwise a containing opaque ancestor is selected. |
+| `onReady`, `onError` | Optical readiness and capture/graphics errors. Native controls remain available. |
+
+The finite spring model is authored for React Glaze. It preserves momentum when interrupted and reaches the exact target with zero velocity at the configured deadline. The timeline does not guarantee a displayed frame at an arbitrary exact millisecond. Hidden/offscreen groups do not keep animating; an explicitly paused sampled frame and a settled endpoint retain their time when the group resumes, while unpaused motion completes before resources are released. Reduced motion removes movement and pulses.
+
+## Optional playback inspection
+
+`GlassGroupHandle` includes `pause()`, `play()`, `seek(timeMs)` and `replay()`. Menu refs use the smaller `GlassMenuHandle`, which exposes the same playback methods and the menu root `element`. Calls made before the controller exists, after disposal or before any authored transition are safe no-ops. `seek()` clamps finite input to the current duration, samples the actual elapsed spring and trigger pulse, paints native hit geometry and GPU geometry together, and pauses at that frame. `play()` resumes from the inspected time without adding paused wall-clock time. `replay()` restores the most recent authored geometry or pulse transition, including a closing transition.
+
+Use `onPlaybackUpdate` to receive `{ time, duration, paused, playing, available }`. Values are finite and bounded. Frame callbacks can arrive at animation-frame frequency, so update inspector text at a lower rate and handle paused, replayed and completed states immediately. Inspection seeks do not repeatedly call `onMotionComplete`; normal playback and replay still report completion. Material, capture and pointer-light updates do not replace the replay checkpoint. Duration or bounce changes keep sampling and reported duration on the same motion configuration. Reduced motion continues to snap instead of exposing an animated inspection timeline.
+
+## Compose custom surfaces
+
+```tsx
+"use client";
+import { useState } from "react";
+import { LiquidGlassGroup, LiquidGlassSurface } from "react-glaze";
+
+export function CustomInteraction() {
+ const [separated, setSeparated] = useState(false);
+ const surfaces = [
+ { id: "one", x: separated ? 100 : 140, y: 90, width: 64, height: 64 },
+ { id: "two", x: separated ? 220 : 180, y: 90, width: 64, height: 64 },
+ ];
+ return (
+
+ setSeparated((value) => !value)}
+ >
+ Move
+
+
+ Read
+
+
+ );
+}
+```
+
+A group has explicit CSS-pixel `width`/`height` and up to eight identified surface geometries. `x` and `y` are center coordinates within that group; `width`, `height` and optional `radius` describe its contour. Radius defaults to half the smaller dimension. Zero dimensions hide a surface. Geometry must be finite and bounded; group dimensions are at most 16,384px and geometry values at most 1,000,000px in magnitude. Use stable IDs to preserve continuity. Current targets take precedence over outgoing retired IDs if replacing many IDs exceeds the eight-surface rendering budget.
+
+`LiquidGlassSurface` binds native content to a surface ID. It supports the wrapper's native tags (`div`, `button`, `a`, `span`, `section`, `article`), native props and typed DOM refs. Geometry controls its position, dimensions and corners; keep typography, padding and content styles in your CSS. `contentScale="follow"` scales content with the surface; `"fixed"` keeps content at its destination scale. Native hit regions share the current animated geometry. Raw group composition does not provide menu roles, open state or keyboard policy; those belong to your interaction.
+
+`initialSurfaces` supplies an optional initial pose. A `GlassGroupHandle` ref exposes `element`, `refresh()` for background invalidation, `pulse(id, 'compress' | 'expand')` and the optional playback controls described above. `onFrame` receives current public geometry, elapsed timeline milliseconds and active state; use it for imperative composition rather than setting React state on every frame. `onMotionComplete` reports when the group's active motion has settled.
+
+## Backgrounds and compatibility
+
+Backgrounds remain ordinary application DOM. The group observes source mutations, image loads, input/focus changes, scrolling, resizing and visible CSS motion. It uses the package's existing snapshot adapter and serialized, bounded capture jobs; snapshots are asynchronous. An explicit small `backdrop` root can reduce capture cost. `refresh()` is available for changes that the browser does not expose through those observations.
+
+No GPU work is required for static SSR fallback. Each visible group owns one graphics context. Hidden, offscreen and disabled groups release it and reacquire it when needed; keep related surfaces in a single group and avoid large numbers of simultaneously visible groups. Native fallback remains when the browser reaches its graphics-context limit. Context loss keeps native controls usable; restoration requests a new source and material environment. Unmount releases observers, listeners, pending capture ownership, motion tracks and GPU resources.
+
+The existing snapshot restrictions still apply: arbitrary video/canvas frames, foreign iframes, inaccessible cross-origin images, complex masks/blending, transformed ancestors and every CSSOM/shadow-DOM composition are not guaranteed. This is an experimental library. Desktop Chrome/Firefox/Safari are targets, not evidence for every version or the new components on untested devices. Physical iPhone and native assistive-technology testing remain application-level acceptance work. See the [package limits](../packages/react/README.md#known-limits).
diff --git a/docs/releasing.md b/docs/releasing.md
index 3538a47..0b6b620 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -2,12 +2,12 @@
The library is the `packages/react` workspace. The root and demo app stay private. Releases use ordinary numeric versions, such as `0.1.0` and `0.2.0`, and the npm `latest` dist-tag. Users install with `npm install react-glaze`.
-1. Update the package version and refresh the root lockfile when needed. Keep the demo pinned to an existing published version until the new release is available. Review the public README and compatibility limits. Check the packed metadata: `homepage` must point to `https://react-glaze.app/`, while `repository` and `bugs` point to GitHub. After publication, verify both the README banner and the Homepage link on npm.
+1. Update the package version and refresh the root lockfile when needed. The demo consumes the built workspace package so the candidate can be reviewed before publication. An independent tarball consumer is mandatory; the workspace link alone is not distribution evidence. Review the public README and compatibility limits. Check the packed metadata: `homepage` must point to `https://react-glaze.app/`, while `repository` and `bugs` point to GitHub. After publication, verify both the README banner and the Homepage link on npm.
2. Run `pnpm install --frozen-lockfile` and `pnpm check` from a clean checkout.
3. Run `pnpm pack:local`. Inspect its archive manifest and install that exact tarball in an independent React application. Check public import, SSR and the relevant browser behavior.
4. Commit the reviewed source and wait for CI. Authenticate with npm locally; never commit a token.
5. Run `npm publish /absolute/path/to/the-reviewed.tgz --tag latest --access public --registry=https://registry.npmjs.org/`. Complete npm's interactive authentication if requested.
-6. Verify the registry version, `latest` tag and tarball integrity. Install from the registry in a fresh consumer. Then update the demo to the exact published version, refresh the lockfile and verify its build and browser behavior.
+6. Verify the registry version, `latest` tag and tarball integrity. Install from the registry in a fresh consumer. Before deploying the demo, verify that its workspace package version and compiled distribution match the reviewed/published archive, then verify the demo build and browser behavior.
7. Tag the matching package source commit `v` and create a GitHub release with the exact archive and release notes.
Publishing a name/version is immutable on npm. If a publish command has an uncertain outcome, inspect the registry before retrying. Do not rebuild or replace an artifact under a version that is already published.
diff --git a/package.json b/package.json
index 333c651..01b4869 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"pack:local": "node scripts/pack-local.mjs",
- "build": "pnpm --filter react-glaze build && pnpm --filter demo build",
+ "build": "pnpm --filter demo build",
"dev": "pnpm --filter demo dev",
"start": "pnpm --filter demo start",
"test": "pnpm --filter react-glaze build && node --test packages/react/tests/*.test.mjs && pnpm --filter react-glaze test:types",
diff --git a/packages/react/README.md b/packages/react/README.md
index 1f7d620..175168d 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -1,8 +1,8 @@
[](https://react-glaze.app/)
-[Website](https://react-glaze.app/) · [Playground](https://react-glaze.app/playground) · [Showcase](https://react-glaze.app/showcase) · [npm](https://www.npmjs.com/package/react-glaze)
+[Website](https://react-glaze.app/) · [Components](https://react-glaze.app/components) · [Liquid Glass editor](https://react-glaze.app/components?component=glass) · [Showcase](https://react-glaze.app/showcase) · [npm](https://www.npmjs.com/package/react-glaze)
-One configurable component for React 19 / Next.js, licensed under [MIT](LICENSE). This is an experimental library. The rounded surface is new; the optical lens, physical material values and studio lighting derive from the pinned Drawn To reference. See [third-party notices](THIRD-PARTY-NOTICES.md).
+Composable liquid glass for React 19 / Next.js, licensed under [MIT](LICENSE). LiquidGlass wraps ordinary content; LiquidGlassActions and LiquidGlassMenu provide native morphing controls; LiquidGlassGroup and LiquidGlassSurface expose the shared material/motion foundation. This is an experimental library. The rounded surface is new; the optical lens, physical material values and studio lighting derive from the pinned Drawn To reference. See [third-party notices](THIRD-PARTY-NOTICES.md).
## Install and wrap
@@ -38,6 +38,32 @@ The default wrapper follows normal `div` flow. It adds no width, height, padding
The background is ordinary page HTML. The component chooses the nearest ancestor with an opaque background color, or `document.body`; callers do not provide an image/source prop. The library combines a DOM snapshot adapter based on pinned html-to-image 1.11.13 helpers with an automatic native-image path and a lazily imported Three.js renderer. Its owned clone adaptation preserves browser-selected responsive images. This does **not** establish arbitrary live-DOM compatibility.
+## Morph components
+
+```tsx
+'use client';
+import { LiquidGlassActions } from 'react-glaze';
+
+export function DocumentActions() {
+ return console.log('Save') },
+ { id: 'share', label: 'Share', onSelect: () => console.log('Share') },
+ ]}
+ appearance="labels" layout="fan" direction="right"
+ distance={-40} actionSize={56}
+ motion={{ duration: 500, bounce: 0.5 }} refraction={1.5}
+ />;
+}
+```
+
+Use `LiquidGlassMenu` for a single trigger-to-panel morph. Menus have at most six items, native keyboard/dismissal behavior, controlled or local open state, disabled actions and viewport fitting. They render in place and follow ancestor clipping/stacking. Duration includes settling; signed distance moves the whole action group without compressing the arc. Content stays native and sharp. `externalControls` can associate one bounded external controls element so its pointer and focus events do not dismiss the menu. `boundary` optionally fits a menu within a stage instead of the visual viewport; scrolling a shared stage and trigger then keeps the same fitting geometry. Paused playback and a settled endpoint retain their sampled time across hidden/offscreen suspension, while unpaused motion completes before resources are released.
+
+For custom interactions, `LiquidGlassGroup` takes explicit dimensions and up to eight identified `{ id, x, y, width, height, radius? }` geometries, with center coordinates in CSS pixels. `LiquidGlassSurface` binds native content and hit regions to the current geometry. Group options include `material`, `motion`, `connection`, `refraction`, `reducedMotion`, `backdrop`, `onReady`, `onError` and `onPlaybackUpdate`. A `GlassGroupHandle` ref exposes `element`, `refresh()`, `pulse()`, `pause()`, `play()`, `seek()` and `replay()`; menu refs use `GlassMenuHandle` with the playback methods and root element. Playback inspection samples the actual spring and pulse, clamps to the current duration and remains a safe no-op until an authored transition exists. Native fallback geometry remains inspectable while loading or when graphics are unavailable. Reduced motion continues to snap.
+
+[Complete morph API, defaults, examples and limits](https://github.com/Stianlars1/react-glaze/blob/main/docs/components.md) · [Interactive guide](https://react-glaze.app/components)
+
## Component contract
- `as`: `div` (default), `button`, `a`, `span`, `section`, `article`. Native attributes/events and typed DOM refs are forwarded. Buttons default to `type="button"`; explicitly use `type="submit"` for form submission. Links use normal `href`.
@@ -187,7 +213,7 @@ No physical iPhone/Android or Windows Edge support guarantee is made by this ver
}
```
-Material presets remain adjustable. `shape` is the outline convenience; `preset` is the material starting point. The playground retains the selected shape when changing material and provides **Match Optical Type** to restore the original material, lens, artwork and proportions together. Manually editing corners shows **Custom corners**. JSX/JSON export uses the same typed prop builder as the live preview and excludes locked radius/height props.
+Material presets remain adjustable. `shape` is the outline convenience; `preset` is the material starting point. The Liquid Glass editor at `/components?component=glass` retains the selected shape when changing material and provides **Match Optical Type** to restore the original material, lens, artwork and proportions together. Manually editing corners shows **Custom corners**. JSX/JSON export uses the same typed prop builder as the live preview and excludes locked radius/height props. `/playground` is a compatibility redirect.
Shape defaults ship as one deduplicated React 19 style resource; consumers do not need a separate stylesheet import. Class/style changes, ancestor theme classes, stylesheet text changes, resize and CSS radius transitions/animations refresh the contour. No idle polling or DOM measurement probe is added. Direct CSSOM insertRule mutations and JavaScript Web Animations started without a DOM/CSS animation event are not automatically tracked by this version. Geometry animation may allocate changing meshes; bounded memory is not a promise of arbitrary animation performance.
diff --git a/packages/react/package.json b/packages/react/package.json
index d85b94e..37832f5 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,9 +1,9 @@
{
"name": "react-glaze",
- "version": "0.1.2",
+ "version": "0.2.0",
"type": "module",
"license": "MIT",
- "description": "Liquid glass for React. A configurable wrapper with automatic background refraction and optional pointer-driven rim lighting.",
+ "description": "Liquid glass for React. Composable glass surfaces, morphing menus and spring-driven action groups.",
"exports": {
".": {
"types": "./dist/index.d.ts",
diff --git a/packages/react/src/LiquidGlassActions.tsx b/packages/react/src/LiquidGlassActions.tsx
new file mode 100644
index 0000000..964bdfa
--- /dev/null
+++ b/packages/react/src/LiquidGlassActions.tsx
@@ -0,0 +1,7 @@
+"use client";
+import type { LiquidGlassActionsProps } from "./morph-types.js";
+import { GlassMenu } from "./morph/menu/Menu.js";
+
+export function LiquidGlassActions(props: LiquidGlassActionsProps) {
+ return ;
+}
diff --git a/packages/react/src/LiquidGlassGroup.tsx b/packages/react/src/LiquidGlassGroup.tsx
new file mode 100644
index 0000000..4c19541
--- /dev/null
+++ b/packages/react/src/LiquidGlassGroup.tsx
@@ -0,0 +1,278 @@
+"use client";
+import {
+ createElement,
+ useEffect,
+ useImperativeHandle,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import type { LiquidGlassGroupProps } from "./morph-types.js";
+import type {
+ MorphController,
+ MorphControllerOptions,
+} from "./morph/controller.js";
+import {
+ groupDimension,
+ groupMaterial,
+ motionOptions,
+ normalizeSurfaces,
+ publicSurface,
+} from "./morph/config.js";
+import { MorphContext, type SurfaceRegistration } from "./morph/context.js";
+import { morphStyles } from "./morph/styles.js";
+import { clamp, type Surface } from "./morph/model.js";
+
+export function LiquidGlassGroup(props: LiquidGlassGroupProps) {
+ const {
+ width: requestedWidth,
+ height: requestedHeight,
+ surfaces,
+ initialSurfaces,
+ children,
+ material,
+ motion,
+ connection = 12,
+ refraction = 1,
+ reducedMotion = false,
+ backdrop,
+ onReady,
+ onError,
+ onFrame,
+ onMotionComplete,
+ onPlaybackUpdate,
+ ref,
+ style,
+ onPointerMove,
+ ...domProps
+ } = props;
+ const width = groupDimension(requestedWidth, "width"),
+ height = groupDimension(requestedHeight, "height");
+ const surfaceKey = JSON.stringify(surfaces);
+ const targets = useMemo(() => normalizeSurfaces(surfaces), [surfaceKey]);
+ const first = useRef(null);
+ if (!first.current)
+ first.current = normalizeSurfaces(initialSurfaces ?? surfaces);
+ const initial = useRef(new Map(first.current.map((s) => [s.id, s])));
+ const host = useRef(null),
+ layer = useRef(null);
+ const controller = useRef(null);
+ const registry = useRef(new Map());
+ const current = useRef(first.current);
+ const pending = useRef(new Map());
+ const firstConfiguration = useRef(true);
+ const [systemReduced, setSystemReduced] = useState(false);
+ const latest = useRef(props);
+ latest.current = props;
+ const targetMap = useMemo(
+ () => new Map(targets.map((s) => [s.id, s])),
+ [targets],
+ );
+ const targetsRef = useRef(targetMap);
+ targetsRef.current = targetMap;
+ const paint = (frame: readonly Surface[]) => {
+ current.current = frame;
+ const poses = new Map(frame.map((s) => [s.id, s]));
+ for (const [id, registration] of registry.current) {
+ const s = poses.get(id),
+ target = targetsRef.current.get(id);
+ const { node, content } = registration;
+ if (!s || s.w <= 0.001 || s.h <= 0.001) {
+ node.style.opacity = "0";
+ node.style.pointerEvents = "none";
+ continue;
+ }
+ if (target && target.w > 0 && target.h > 0) {
+ registration.width = target.w;
+ registration.height = target.h;
+ }
+ const w = Math.max(1, registration.width),
+ h = Math.max(1, registration.height);
+ const sx = Math.max(0.001, s.w / w),
+ sy = Math.max(0.001, s.h / h);
+ node.style.width = `${w}px`;
+ node.style.height = `${h}px`;
+ node.style.transform = `translate(${s.x - w / 2}px,${s.y - h / 2}px) scale(${sx},${sy})`;
+ node.style.borderRadius = `${s.r / sx}px / ${s.r / sy}px`;
+ node.style.opacity = String(clamp((Math.min(sx, sy) - 0.2) / 0.65));
+ node.style.pointerEvents =
+ target && target.w > 0 && target.h > 0 ? "auto" : "none";
+ content.style.transform =
+ registration.contentScale === "fixed"
+ ? `scale(${1 / sx},${1 / sy})`
+ : "none";
+ }
+ };
+ const paintFallback = (frame: readonly Surface[]) => {
+ paint(frame);
+ latest.current.onFrame?.({
+ surfaces: frame.map(publicSurface),
+ elapsed: 0,
+ active: false,
+ });
+ };
+ const context = useMemo(
+ () => ({
+ initial: initial.current,
+ targets: targetMap,
+ register(id: string, registration: SurfaceRegistration) {
+ if (registry.current.has(id))
+ throw new Error(
+ `Only one LiquidGlassSurface may register '${id}' in a group.`,
+ );
+ registry.current.set(id, registration);
+ paint(current.current);
+ return () => {
+ if (registry.current.get(id) === registration)
+ registry.current.delete(id);
+ };
+ },
+ }),
+ [targetMap],
+ );
+ const options: MorphControllerOptions = {
+ width,
+ height,
+ surfaces: targets,
+ material: groupMaterial(material),
+ motion: motionOptions(motion),
+ connection: Number.isFinite(connection) ? clamp(connection, 0, 48) : 12,
+ refraction: Number.isFinite(refraction) ? clamp(refraction, 0, 3) : 1,
+ reduced: reducedMotion || systemReduced,
+ backdrop: () => latest.current.backdrop?.current,
+ };
+ const optionsRef = useRef(options);
+ optionsRef.current = options;
+ const configKey = JSON.stringify({ ...options, backdrop: undefined });
+ const layoutKey = JSON.stringify([
+ style,
+ domProps.className,
+ domProps.id,
+ domProps.hidden,
+ ]);
+ useImperativeHandle(
+ ref,
+ () => ({
+ get element() {
+ return host.current;
+ },
+ refresh() {
+ controller.current?.refresh();
+ },
+ pulse(id, kind) {
+ if (controller.current) controller.current.pulse(id, kind);
+ else pending.current.set(id, kind);
+ },
+ pause() {
+ controller.current?.pause();
+ },
+ play() {
+ controller.current?.play();
+ },
+ seek(timeMs) {
+ controller.current?.seek(timeMs);
+ },
+ replay() {
+ controller.current?.replay();
+ },
+ }),
+ [],
+ );
+ useEffect(() => {
+ const media = matchMedia("(prefers-reduced-motion: reduce)");
+ const changed = () => setSystemReduced(media.matches);
+ changed();
+ media.addEventListener("change", changed);
+ return () => media.removeEventListener("change", changed);
+ }, []);
+ useEffect(() => {
+ let cancelled = false;
+ let instance: MorphController | undefined;
+ import("./morph/controller.js")
+ .then(({ MorphController }) => {
+ if (cancelled || !host.current || !layer.current) return;
+ const settings = {
+ ...optionsRef.current,
+ reduced:
+ optionsRef.current.reduced ||
+ matchMedia("(prefers-reduced-motion: reduce)").matches,
+ };
+ instance = new MorphController(
+ host.current,
+ layer.current,
+ settings,
+ {
+ paint,
+ onFrame: (frame) => latest.current.onFrame?.(frame),
+ onReady: () => latest.current.onReady?.(),
+ onError: (error) => latest.current.onError?.(error),
+ onMotionComplete: () => latest.current.onMotionComplete?.(),
+ onPlaybackUpdate: (state) =>
+ latest.current.onPlaybackUpdate?.(state),
+ },
+ [...current.current],
+ );
+ controller.current = instance;
+ for (const [id, kind] of pending.current) instance.pulse(id, kind);
+ pending.current.clear();
+ })
+ .catch((error) => {
+ if (cancelled) return;
+ if (host.current) host.current.dataset.glazeReady = "false";
+ paintFallback(normalizeSurfaces(latest.current.surfaces));
+ latest.current.onError?.(
+ error instanceof Error ? error : new Error(String(error)),
+ );
+ });
+ return () => {
+ cancelled = true;
+ instance?.dispose();
+ if (controller.current === instance) controller.current = null;
+ };
+ }, []);
+ useLayoutEffect(() => {
+ if (controller.current) {
+ controller.current.update(optionsRef.current);
+ paint(current.current);
+ } else if (!firstConfiguration.current)
+ paintFallback(optionsRef.current.surfaces);
+ firstConfiguration.current = false;
+ }, [configKey, layoutKey, backdrop]);
+ return (
+
+ {createElement(
+ "style",
+ { href: "react-glaze-morph-v1", precedence: "react-glaze" },
+ morphStyles,
+ )}
+ {
+ onPointerMove?.(event);
+ if (!event.defaultPrevented && event.pointerType !== "touch")
+ controller.current?.pointer(event.clientX, event.clientY);
+ }}
+ >
+
+ {children}
+
+
+ );
+}
diff --git a/packages/react/src/LiquidGlassMenu.tsx b/packages/react/src/LiquidGlassMenu.tsx
new file mode 100644
index 0000000..e36ca18
--- /dev/null
+++ b/packages/react/src/LiquidGlassMenu.tsx
@@ -0,0 +1,7 @@
+"use client";
+import type { LiquidGlassMenuProps } from "./morph-types.js";
+import { GlassMenu } from "./morph/menu/Menu.js";
+
+export function LiquidGlassMenu(props: LiquidGlassMenuProps) {
+ return ;
+}
diff --git a/packages/react/src/LiquidGlassSurface.tsx b/packages/react/src/LiquidGlassSurface.tsx
new file mode 100644
index 0000000..e2e41dd
--- /dev/null
+++ b/packages/react/src/LiquidGlassSurface.tsx
@@ -0,0 +1,80 @@
+"use client";
+import {
+ createElement,
+ forwardRef,
+ useContext,
+ useLayoutEffect,
+ useRef,
+} from "react";
+import type { HTMLAttributes, ReactNode } from "react";
+import type { LiquidGlassSurfaceComponent } from "./morph-types.js";
+import type { GlassTag } from "./types.js";
+import { MorphContext } from "./morph/context.js";
+
+type Props = HTMLAttributes & {
+ as?: GlassTag;
+ surfaceId: string;
+ children?: ReactNode;
+ contentScale?: "follow" | "fixed";
+};
+export const LiquidGlassSurface = forwardRef(
+ function LiquidGlassSurface(
+ {
+ as = "div",
+ surfaceId,
+ children,
+ contentScale = "follow",
+ style,
+ inert,
+ ...props
+ },
+ forwardedRef,
+ ) {
+ const context = useContext(MorphContext);
+ if (!context)
+ throw new Error("LiquidGlassSurface must be inside LiquidGlassGroup.");
+ const host = useRef(null),
+ content = useRef(null);
+ const target = context.targets.get(surfaceId);
+ const start = useRef(context.initial.get(surfaceId) ?? target);
+ const initial = start.current;
+ useLayoutEffect(() => {
+ if (!host.current || !content.current) return;
+ return context.register(surfaceId, {
+ node: host.current,
+ content: content.current,
+ contentScale,
+ width: target?.w || initial?.w || 1,
+ height: target?.h || initial?.h || 1,
+ });
+ }, [context, surfaceId, contentScale, as]);
+ return createElement(
+ as,
+ {
+ ...(as === "button" ? { type: "button" } : {}),
+ ...props,
+ ref: (node: HTMLElement | null) => {
+ host.current = node;
+ if (typeof forwardedRef === "function") forwardedRef(node);
+ else if (forwardedRef) forwardedRef.current = node;
+ },
+ "data-glaze-surface": surfaceId,
+ inert: inert || !target || target.w <= 0 || target.h <= 0,
+ style: {
+ ...style,
+ position: "absolute",
+ width: initial?.w ?? 0,
+ height: initial?.h ?? 0,
+ transform: `translate(${(initial?.x ?? 0) - (initial?.w ?? 0) / 2}px,${(initial?.y ?? 0) - (initial?.h ?? 0) / 2}px)`,
+ borderRadius: initial?.r ?? 0,
+ opacity: initial && initial.w > 0 && initial.h > 0 ? 1 : 0,
+ },
+ },
+ createElement(
+ "span",
+ { ref: content, "data-glaze-content": "" },
+ children,
+ ),
+ );
+ },
+) as LiquidGlassSurfaceComponent;
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index a759598..851b6da 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -1,5 +1,15 @@
"use client";
export { LiquidGlass } from "./LiquidGlass.js";
+export { LiquidGlassGroup } from "./LiquidGlassGroup.js";
+export { LiquidGlassSurface } from "./LiquidGlassSurface.js";
+export { LiquidGlassActions } from "./LiquidGlassActions.js";
+export { LiquidGlassMenu } from "./LiquidGlassMenu.js";
+export type {
+ GlassSurface, GlassMotionOptions, GlassGroupMaterial, GlassMorphFrame,
+ GlassPlaybackState, GlassPlaybackControls, GlassGroupHandle, GlassMenuHandle,
+ GlassGroupOptions, LiquidGlassGroupProps, LiquidGlassSurfaceProps,
+ GlassAction, GlassDirection, GlassActionLayout, LiquidGlassActionsProps, LiquidGlassMenuProps,
+} from "./morph-types.js";
export {
REFERENCE,
PRESETS,
diff --git a/packages/react/src/morph-types.ts b/packages/react/src/morph-types.ts
new file mode 100644
index 0000000..b37c321
--- /dev/null
+++ b/packages/react/src/morph-types.ts
@@ -0,0 +1,154 @@
+import type {
+ ComponentPropsWithoutRef,
+ CSSProperties,
+ ReactElement,
+ ReactNode,
+ Ref,
+ RefObject,
+} from "react";
+import type {
+ GlassMaterial,
+ GlassPreset,
+ GlassSettings,
+ GlassTag,
+} from "./types.js";
+
+/** Coordinates are CSS pixels; x and y are the surface center in its group. */
+export interface GlassSurface {
+ id: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ radius?: number;
+}
+export interface GlassMotionOptions {
+ /** Total animation time, including settling, in milliseconds (100-3000). */
+ duration?: number;
+ /** Overshoot, from 0 (none) to 1. */
+ bounce?: number;
+}
+export type GlassGroupMaterial = Partial<
+ GlassMaterial &
+ Pick<
+ GlassSettings,
+ | "enabled"
+ | "optics"
+ | "lighting"
+ | "depth"
+ | "exposure"
+ | "shadowOpacity"
+ | "maxDpr"
+ >
+> & { preset?: GlassPreset };
+export interface GlassMorphFrame {
+ surfaces: readonly GlassSurface[];
+ elapsed: number;
+ active: boolean;
+}
+export interface GlassPlaybackState {
+ time: number;
+ duration: number;
+ paused: boolean;
+ playing: boolean;
+ available: boolean;
+}
+export interface GlassPlaybackControls {
+ pause(): void;
+ play(): void;
+ seek(timeMs: number): void;
+ replay(): void;
+}
+export interface GlassGroupHandle extends GlassPlaybackControls {
+ readonly element: HTMLDivElement | null;
+ refresh(): void;
+ pulse(id: string, kind: "compress" | "expand"): void;
+}
+export interface GlassMenuHandle extends GlassPlaybackControls {
+ readonly element: HTMLDivElement | null;
+}
+export interface GlassGroupOptions {
+ material?: GlassGroupMaterial;
+ motion?: GlassMotionOptions;
+ /** Smoothing distance between surfaces, in CSS pixels (0-48). */
+ connection?: number;
+ /** Multiplier of the material's thickness, from 0 to 3. */
+ refraction?: number;
+ /** Force reduced motion. The system preference is always respected. */
+ reducedMotion?: boolean;
+ /** Optional explicit backdrop root. Defaults to a containing opaque ancestor. */
+ backdrop?: RefObject;
+ onReady?: () => void;
+ onError?: (error: Error) => void;
+ onPlaybackUpdate?: (state: GlassPlaybackState) => void;
+}
+export type LiquidGlassGroupProps = GlassGroupOptions &
+ Omit<
+ ComponentPropsWithoutRef<"div">,
+ keyof GlassGroupOptions | "children"
+ > & {
+ width: number;
+ height: number;
+ surfaces: readonly GlassSurface[];
+ initialSurfaces?: readonly GlassSurface[];
+ children?: ReactNode;
+ onFrame?: (frame: GlassMorphFrame) => void;
+ onMotionComplete?: () => void;
+ ref?: Ref;
+ };
+export type LiquidGlassSurfaceProps = {
+ as?: T;
+ surfaceId: string;
+ children?: ReactNode;
+ /** Keep content at its final size while the contour moves around it. */
+ contentScale?: "follow" | "fixed";
+} & Omit, "as" | "children">;
+export type LiquidGlassSurfaceComponent = (
+ props: LiquidGlassSurfaceProps & { ref?: Ref },
+) => ReactElement;
+
+export interface GlassAction {
+ id: string;
+ label: string;
+ icon?: ReactNode;
+ /** Optional custom visible content; label still supplies the accessible name. */
+ content?: ReactNode;
+ disabled?: boolean;
+ onSelect?: () => void;
+}
+export type GlassDirection = "left" | "top" | "right" | "bottom";
+export type GlassActionLayout = "row" | "column" | "fan";
+export interface GlassMenuBaseProps extends GlassGroupOptions {
+ /** Accessible name for the trigger. */
+ label: string;
+ trigger?: ReactNode;
+ /** Up to six actions. Empty lists disable the trigger. */
+ items: readonly GlassAction[];
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ /** Trigger diameter in CSS pixels, from 44 to 100. */
+ triggerSize?: number;
+ appearance?: "icons" | "labels";
+ className?: string;
+ style?: CSSProperties;
+ triggerClassName?: string;
+ itemClassName?: string;
+ disabled?: boolean;
+ /** Associate a bounded element containing controls that operate this menu. */
+ externalControls?: RefObject;
+ /** Optional collision boundary. Defaults to the visual viewport. */
+ boundary?: RefObject;
+ ref?: Ref;
+}
+export interface LiquidGlassActionsProps extends GlassMenuBaseProps {
+ layout?: GlassActionLayout;
+ direction?: GlassDirection;
+ /** Rigid group offset from recommended placement, in CSS pixels (-160 to 120). */
+ distance?: number;
+ /** Action diameter/height in CSS pixels (44-100); labels may widen it. */
+ actionSize?: number;
+}
+export interface LiquidGlassMenuProps extends GlassMenuBaseProps {
+ direction?: GlassDirection;
+}
diff --git a/packages/react/src/morph/config.ts b/packages/react/src/morph/config.ts
new file mode 100644
index 0000000..eb76bad
--- /dev/null
+++ b/packages/react/src/morph/config.ts
@@ -0,0 +1,89 @@
+import { resolveSettings } from "../presets.js";
+import type {
+ GlassGroupMaterial,
+ GlassMotionOptions,
+ GlassSurface,
+} from "../morph-types.js";
+import { MAX_SURFACES, type Surface } from "./model.js";
+import {
+ DEFAULT_BOUNCE,
+ DEFAULT_DURATION,
+ MAX_DURATION,
+ MIN_DURATION,
+} from "./motion-config.js";
+
+export function groupMaterial(material: GlassGroupMaterial = {}) {
+ return resolveSettings({
+ preset: "reference",
+ lighting: "responsive",
+ maxDpr: 2,
+ ...material,
+ shape: "rounded",
+ contentMode: "sharp",
+ });
+}
+export function motionOptions(options: GlassMotionOptions = {}) {
+ const duration = options.duration ?? DEFAULT_DURATION,
+ bounce = options.bounce ?? DEFAULT_BOUNCE;
+ return {
+ duration: Number.isFinite(duration)
+ ? Math.max(MIN_DURATION, Math.min(MAX_DURATION, duration))
+ : DEFAULT_DURATION,
+ bounce: Number.isFinite(bounce)
+ ? Math.max(0, Math.min(1, bounce))
+ : DEFAULT_BOUNCE,
+ };
+}
+export function normalizeSurfaces(values: readonly GlassSurface[]): Surface[] {
+ if (values.length > MAX_SURFACES)
+ throw new RangeError(
+ `LiquidGlassGroup supports at most ${MAX_SURFACES} surfaces.`,
+ );
+ const ids = new Set();
+ return values.map((value) => {
+ if (!value.id || ids.has(value.id))
+ throw new TypeError(
+ "LiquidGlassGroup surface IDs must be nonempty and unique.",
+ );
+ ids.add(value.id);
+ const radius = value.radius ?? Math.min(value.width, value.height) / 2;
+ if (
+ ![value.x, value.y, value.width, value.height, radius].every(
+ (n) => Number.isFinite(n) && Math.abs(n) <= 1_000_000,
+ )
+ )
+ throw new RangeError(
+ `LiquidGlassGroup surface '${value.id}' must have finite geometry within 1,000,000 CSS pixels.`,
+ );
+ const w = Math.max(0, value.width),
+ h = Math.max(0, value.height);
+ return {
+ id: value.id,
+ x: value.x,
+ y: value.y,
+ w,
+ h,
+ r: Math.max(0, Math.min(radius, w / 2, h / 2)),
+ };
+ });
+}
+export function drawableSurfaces(
+ frame: readonly Surface[],
+ targets: readonly Surface[],
+) {
+ const present = new Set(targets.map((s) => s.id));
+ return frame
+ .filter((s) => s.w > 0.01 && s.h > 0.01)
+ .sort((a, b) => Number(present.has(b.id)) - Number(present.has(a.id)))
+ .slice(0, MAX_SURFACES);
+}
+export function publicSurface(s: Surface): GlassSurface {
+ return { id: s.id, x: s.x, y: s.y, width: s.w, height: s.h, radius: s.r };
+}
+export function groupDimension(value: number, name: string) {
+ if (!Number.isFinite(value) || value <= 0 || value > 16384)
+ throw new RangeError(
+ `LiquidGlassGroup ${name} must be a finite CSS pixel size greater than 0 and at most 16384.`,
+ );
+ return value;
+}
diff --git a/packages/react/src/morph/context.ts b/packages/react/src/morph/context.ts
new file mode 100644
index 0000000..b9cc1de
--- /dev/null
+++ b/packages/react/src/morph/context.ts
@@ -0,0 +1,15 @@
+import { createContext } from "react";
+import type { Surface } from "./model.js";
+export interface SurfaceRegistration {
+ node: HTMLElement;
+ content: HTMLSpanElement;
+ contentScale: "follow" | "fixed";
+ width: number;
+ height: number;
+}
+export interface MorphContextValue {
+ initial: ReadonlyMap;
+ targets: ReadonlyMap;
+ register(id: string, registration: SurfaceRegistration): () => void;
+}
+export const MorphContext = createContext(null);
diff --git a/packages/react/src/morph/controller.ts b/packages/react/src/morph/controller.ts
new file mode 100644
index 0000000..5d8c3f7
--- /dev/null
+++ b/packages/react/src/morph/controller.ts
@@ -0,0 +1,446 @@
+import type { GlassSettings } from "../types.js";
+import type { GlassMorphFrame, GlassPlaybackState } from "../morph-types.js";
+import {
+ MotionState,
+ retimeSnapshot,
+ type MotionOptions,
+ type MotionSnapshot,
+} from "./motion.js";
+import type { Surface } from "./model.js";
+import { drawableSurfaces, publicSurface } from "./config.js";
+import { MorphRenderer } from "./renderer.js";
+import { MorphSource } from "./source.js";
+
+export interface MorphControllerOptions {
+ width: number;
+ height: number;
+ surfaces: Surface[];
+ material: GlassSettings;
+ motion: MotionOptions;
+ connection: number;
+ refraction: number;
+ reduced: boolean;
+ backdrop: () => HTMLElement | null | undefined;
+}
+export interface MorphControllerCallbacks {
+ paint: (surfaces: readonly Surface[]) => void;
+ onFrame: (frame: GlassMorphFrame) => void;
+ onReady: () => void;
+ onError: (error: Error) => void;
+ onMotionComplete: () => void;
+ onPlaybackUpdate?: (state: GlassPlaybackState) => void;
+}
+export class MorphController {
+ private motion = new MotionState();
+ private renderer?: MorphRenderer;
+ private canvas?: HTMLCanvasElement;
+ private source?: MorphSource;
+ private intersection: IntersectionObserver;
+ private visibilityMutation: MutationObserver;
+ private abort = new AbortController();
+ private raf = 0;
+ private previous = 0;
+ private visible = false;
+ private disposed = false;
+ private ready = false;
+ private sourceValid = false;
+ private pendingComplete = false;
+ private movedSource = false;
+ private bounds = "";
+ private rendererAttempted = false;
+ private dpr = 0;
+ private backdrop?: HTMLElement | null;
+ private captureMaxDpr = 0;
+ private light = { x: -1, y: -1 };
+ private checkpoint?: MotionSnapshot;
+ private playbackPaused = false;
+
+ constructor(
+ private host: HTMLDivElement,
+ private layer: HTMLDivElement,
+ private options: MorphControllerOptions,
+ private callbacks: MorphControllerCallbacks,
+ initial: Surface[],
+ ) {
+ this.motion.configure(options.motion);
+ this.motion.retarget(initial);
+ this.motion.step(0, true);
+ this.visible = this.inViewport();
+ this.intersection = new IntersectionObserver(() => this.updateVisibility());
+ this.intersection.observe(host);
+ this.visibilityMutation = new MutationObserver((records) => {
+ if (
+ records.some(
+ (record) =>
+ record.target !== this.host ||
+ record.attributeName !== "data-glaze-ready",
+ ) &&
+ this.inViewport() !== this.visible
+ )
+ this.updateVisibility();
+ });
+ // Ancestor attributes can hide the group while no graphics/source exists.
+ // Observe each element itself, excluding per-frame descendant overlays.
+ for (let node: HTMLElement | null = host; node; node = node.parentElement)
+ this.visibilityMutation.observe(node, { attributes: true });
+ const settings = { signal: this.abort.signal };
+ document.addEventListener(
+ "visibilitychange",
+ () => this.updateVisibility(),
+ settings,
+ );
+ window.addEventListener("resize", () => this.updateVisibility(), settings);
+ this.update(options);
+ this.emitPlayback();
+ }
+ private inViewport() {
+ const rect = this.host.getBoundingClientRect(),
+ viewport = window.visualViewport;
+ const left = viewport?.offsetLeft ?? 0,
+ top = viewport?.offsetTop ?? 0;
+ return (
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.right > left &&
+ rect.bottom > top &&
+ rect.left < left + (viewport?.width ?? innerWidth) &&
+ rect.top < top + (viewport?.height ?? innerHeight) &&
+ getComputedStyle(this.host).visibility === "visible"
+ );
+ }
+ private initializeRenderer() {
+ if (
+ this.renderer ||
+ this.rendererAttempted ||
+ !this.options.material.enabled ||
+ !this.visible ||
+ document.hidden
+ )
+ return;
+ if (
+ ![...this.motion.surfaces, ...this.options.surfaces].some(
+ (s) => s.w > 0.01 && s.h > 0.01,
+ )
+ )
+ return;
+ this.rendererAttempted = true;
+ const options = this.options;
+ const canvas = this.host.ownerDocument.createElement("canvas");
+ canvas.dataset.glazeCanvas = "";
+ canvas.dataset.liquidLayer = "";
+ canvas.setAttribute("aria-hidden", "true");
+ canvas.style.width = "100%";
+ canvas.style.height = "100%";
+ this.layer.append(canvas);
+ this.canvas = canvas;
+ try {
+ this.renderer = new MorphRenderer(
+ canvas,
+ options.material,
+ (message) => this.fail(new Error(message)),
+ () => {
+ this.source?.setActive(
+ this.visible && !document.hidden && this.options.material.enabled,
+ );
+ this.refresh();
+ this.wake();
+ },
+ );
+ this.renderer.configure(options.material, options.refraction);
+ this.renderer.setLight(this.light.x, this.light.y);
+ this.resizeRenderer();
+ this.source = new MorphSource(
+ this.host,
+ () => ({
+ backdrop: this.options.backdrop(),
+ maxDpr: this.options.material.maxDpr,
+ }),
+ (source) => {
+ this.renderer?.setSource(source);
+ this.sourceValid = true;
+ this.wake();
+ },
+ (error) => this.fail(error),
+ );
+ } catch (error) {
+ this.releaseRenderer();
+ this.rendererAttempted = true;
+ this.fail(error);
+ }
+ }
+ private resizeRenderer() {
+ this.renderer?.resize({ w: this.options.width, h: this.options.height });
+ this.dpr = devicePixelRatio || 1;
+ }
+ private clearReady() {
+ this.ready = false;
+ this.sourceValid = false;
+ this.host.dataset.glazeReady = "false";
+ }
+ private releaseRenderer() {
+ this.clearReady();
+ this.source?.dispose();
+ this.source = undefined;
+ this.renderer?.dispose();
+ this.renderer = undefined;
+ this.canvas?.remove();
+ this.canvas = undefined;
+ this.rendererAttempted = false;
+ this.bounds = "";
+ this.movedSource = false;
+ }
+ private fail(error: unknown) {
+ if (this.disposed) return;
+ this.clearReady();
+ if (this.renderer && !this.renderer.available)
+ this.source?.setActive(false);
+ this.callbacks.onError(
+ error instanceof Error ? error : new Error(String(error)),
+ );
+ }
+ update(options: MorphControllerOptions) {
+ if (this.disposed) return;
+ const backdrop = options.backdrop();
+ const sourceChanged =
+ backdrop !== this.backdrop ||
+ options.material.maxDpr !== this.captureMaxDpr;
+ // The previous options getter can already resolve the new React ref.
+ this.backdrop = backdrop;
+ this.captureMaxDpr = options.material.maxDpr;
+ const configChanged =
+ JSON.stringify(this.options.motion) !== JSON.stringify(options.motion);
+ const resized =
+ options.width !== this.options.width ||
+ options.height !== this.options.height ||
+ options.material.maxDpr !== this.options.material.maxDpr;
+ const active = this.motion.active;
+ if (!this.options.material.enabled && options.material.enabled)
+ this.rendererAttempted = false;
+ this.options = options;
+ const motionChanged = this.motion.configure(options.motion);
+ const geometryChanged = this.motion.retarget(options.surfaces);
+ if (geometryChanged || (motionChanged && this.motion.active))
+ this.beginPlayback(!this.playbackPaused);
+ else if (motionChanged && this.checkpoint)
+ this.checkpoint = retimeSnapshot(
+ this.checkpoint,
+ this.motion.snapshot().options,
+ );
+ if (!active || configChanged) this.previous = performance.now();
+ this.pendingComplete ||= this.motion.active;
+ if (options.reduced) {
+ this.motion.step(0, true);
+ this.light = { x: -1, y: -1 };
+ }
+ this.paint(false);
+ this.visible = this.inViewport();
+ if (!this.visible || document.hidden) {
+ this.suspend();
+ return;
+ }
+ if (!options.material.enabled) this.releaseRenderer();
+ else this.initializeRenderer();
+ this.renderer?.configure(options.material, options.refraction);
+ this.renderer?.setLight(this.light.x, this.light.y);
+ if (resized || this.dpr !== (devicePixelRatio || 1)) this.resizeRenderer();
+ this.source?.setActive(this.renderer?.available ?? false);
+ if (sourceChanged) {
+ this.clearReady();
+ this.source?.refresh();
+ }
+ this.wake();
+ }
+ refresh() {
+ if (!this.visible && this.inViewport()) {
+ this.visible = true;
+ this.initializeRenderer();
+ }
+ this.source?.refresh();
+ }
+ pulse(id: string, kind: "compress" | "expand") {
+ if (
+ this.disposed ||
+ this.options.reduced ||
+ !this.visible ||
+ document.hidden
+ )
+ return;
+ if (!this.motion.pulse(id, kind)) return;
+ this.beginPlayback();
+ this.previous = performance.now();
+ this.pendingComplete = this.motion.active;
+ this.wake();
+ }
+ pause() {
+ if (this.disposed || !this.checkpoint || this.options.reduced) return;
+ this.playbackPaused = true;
+ cancelAnimationFrame(this.raf);
+ this.raf = 0;
+ this.previous = 0;
+ this.emitPlayback();
+ }
+ play() {
+ if (this.disposed || !this.checkpoint || this.options.reduced) return;
+ this.playbackPaused = false;
+ this.previous = performance.now();
+ this.pendingComplete ||= this.motion.active;
+ this.emitPlayback();
+ if (this.motion.active) this.wake();
+ }
+ seek(timeMs: number) {
+ if (this.disposed || !this.checkpoint || this.options.reduced) return;
+ const time = Number.isFinite(timeMs)
+ ? Math.min(this.motion.durationMs, Math.max(0, timeMs))
+ : 0;
+ cancelAnimationFrame(this.raf);
+ this.raf = 0;
+ this.previous = 0;
+ this.playbackPaused = true;
+ this.pendingComplete = false;
+ this.motion.restore(this.checkpoint);
+ this.motion.step(time / 1000);
+ this.paint(true);
+ }
+ replay() {
+ if (this.disposed || !this.checkpoint || this.options.reduced) return;
+ this.motion.restore(this.checkpoint);
+ this.playbackPaused = false;
+ this.previous = performance.now();
+ this.pendingComplete = this.motion.active;
+ this.paint(true);
+ if (this.motion.active) this.wake();
+ }
+ private beginPlayback(resume = true) {
+ if (this.options.reduced) {
+ this.checkpoint = undefined;
+ this.playbackPaused = false;
+ return;
+ }
+ this.checkpoint = this.motion.snapshot();
+ if (resume) this.playbackPaused = false;
+ }
+ private playbackState(): GlassPlaybackState {
+ const duration = Number.isFinite(this.motion.durationMs)
+ ? Math.max(0, this.motion.durationMs)
+ : 0;
+ const time = Number.isFinite(this.motion.elapsedMs)
+ ? Math.min(duration, Math.max(0, this.motion.elapsedMs))
+ : 0;
+ const available = !!this.checkpoint && !this.options.reduced;
+ return {
+ time,
+ duration,
+ paused: available && this.playbackPaused,
+ playing: available && !this.playbackPaused && this.motion.active,
+ available,
+ };
+ }
+ private emitPlayback() {
+ this.callbacks.onPlaybackUpdate?.(this.playbackState());
+ }
+ pointer(x: number, y: number) {
+ if (this.options.reduced || !this.renderer) return;
+ const rect = this.host.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
+ const px = ((x - rect.left) * this.options.width) / rect.width,
+ py = ((y - rect.top) * this.options.height) / rect.height;
+ const nearest = this.motion.surfaces
+ .filter((s) => s.w > 0)
+ .sort(
+ (a, b) =>
+ Math.hypot(a.x - px, a.y - py) - Math.hypot(b.x - px, b.y - py),
+ )[0];
+ if (nearest) {
+ this.light = { x: px - nearest.x, y: py - nearest.y };
+ this.renderer.setLight(this.light.x, this.light.y);
+ this.wake();
+ }
+ }
+ private suspend() {
+ cancelAnimationFrame(this.raf);
+ this.raf = 0;
+ this.previous = 0;
+ if (!this.playbackPaused)
+ this.motion.step(this.motion.durationMs / 1000);
+ this.releaseRenderer();
+ this.paint(false);
+ }
+ private updateVisibility() {
+ if (this.disposed) return;
+ this.visible = this.inViewport();
+ if (!this.visible || document.hidden) {
+ this.suspend();
+ return;
+ }
+ this.initializeRenderer();
+ if (this.dpr !== (devicePixelRatio || 1)) this.resizeRenderer();
+ this.source?.setActive(
+ this.options.material.enabled && (this.renderer?.available ?? false),
+ );
+ this.refresh();
+ this.wake();
+ }
+ private wake() {
+ if (!this.disposed && !this.raf && this.visible && !document.hidden)
+ this.raf = requestAnimationFrame(this.tick);
+ }
+ private paint(render: boolean) {
+ const surfaces = this.motion.surfaces;
+ this.callbacks.paint(surfaces);
+ if (
+ render &&
+ this.sourceValid &&
+ this.renderer?.draw(
+ drawableSurfaces(surfaces, this.options.surfaces),
+ this.options.connection,
+ ) &&
+ !this.ready
+ ) {
+ this.ready = true;
+ this.host.dataset.glazeReady = "true";
+ this.callbacks.onReady();
+ }
+ this.callbacks.onFrame({
+ surfaces: surfaces.map(publicSurface),
+ elapsed: this.motion.elapsedMs,
+ active: this.motion.active,
+ });
+ const rect = this.host.getBoundingClientRect(),
+ bounds = `${rect.x},${rect.y},${rect.width},${rect.height}`;
+ if (bounds !== this.bounds) {
+ this.bounds = bounds;
+ this.movedSource ||= this.motion.active;
+ this.source?.refresh(this.motion.active);
+ }
+ if (!this.motion.active && this.pendingComplete) {
+ this.pendingComplete = false;
+ if (this.movedSource) {
+ this.movedSource = false;
+ this.source?.refresh();
+ }
+ this.callbacks.onMotionComplete();
+ }
+ this.emitPlayback();
+ }
+ private tick = (now: number) => {
+ this.raf = 0;
+ if (this.disposed) return;
+ const dt = this.previous ? Math.max(0, (now - this.previous) / 1000) : 0;
+ this.previous = now;
+ if (!this.playbackPaused) this.motion.step(dt, this.options.reduced);
+ this.paint(true);
+ if (this.motion.active && !this.playbackPaused) this.wake();
+ else this.previous = 0;
+ };
+ dispose() {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.abort.abort();
+ this.intersection.disconnect();
+ this.visibilityMutation.disconnect();
+ cancelAnimationFrame(this.raf);
+ this.releaseRenderer();
+ this.motion.dispose();
+ this.checkpoint = undefined;
+ }
+}
diff --git a/packages/react/src/morph/field.ts b/packages/react/src/morph/field.ts
new file mode 100644
index 0000000..6f89110
--- /dev/null
+++ b/packages/react/src/morph/field.ts
@@ -0,0 +1,23 @@
+export const FIELD_GLSL = /* glsl */ `
+uniform vec4 uShapes[8];
+uniform float uRadii[8];
+uniform int uCount;
+uniform float uBridge;
+vec4 field(vec2 p) {
+ vec4 result = vec4(100000.0, 1.0, 1.0, 1.0);
+ for (int i = 0; i < 8; i++) {
+ if (i >= uCount) break;
+ vec4 s = uShapes[i];
+ if (min(s.z, s.w) < 0.01) continue;
+ float radius = min(uRadii[i], min(s.z, s.w) * 0.5);
+ vec2 q = abs(p - s.xy) - s.zw * 0.5 + radius;
+ float distance = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - radius;
+ vec4 next = vec4(distance, s.z, s.w, radius);
+ float reach = max(0.001, uBridge);
+ float blend = clamp(0.5 + 0.5 * (next.x - result.x) / reach, 0.0, 1.0);
+ result = mix(next, result, blend);
+ result.x -= reach * blend * (1.0 - blend);
+ }
+ return result;
+}
+`;
diff --git a/packages/react/src/morph/menu/Menu.tsx b/packages/react/src/morph/menu/Menu.tsx
new file mode 100644
index 0000000..f767128
--- /dev/null
+++ b/packages/react/src/morph/menu/Menu.tsx
@@ -0,0 +1,147 @@
+"use client";
+import { createElement, useId, useImperativeHandle, useLayoutEffect, useRef } from "react";
+import type { GlassAction, GlassGroupHandle, GlassMenuHandle, GlassMorphFrame, LiquidGlassActionsProps } from "../../morph-types.js";
+import { LiquidGlassGroup } from "../../LiquidGlassGroup.js";
+import { LiquidGlassSurface } from "../../LiquidGlassSurface.js";
+import { ANCHOR_ID, TRIGGER_ID, actionSurfaceId, makeMenuLayout } from "./layout.js";
+import { boundedSize, panelContentOpacity, validateActions, validateContent } from "./logic.js";
+import { useMenuMeasurements } from "./use-measurements.js";
+import { useMenu } from "./use-menu.js";
+import { menuStyles } from "./styles.js";
+
+export function GlassMenu({ mode, ...props }: LiquidGlassActionsProps & { mode: "split" | "panel" }) {
+ if (typeof props.label !== "string" || !props.label.trim()) throw new TypeError("Glass menus need a nonempty trigger label.");
+ validateActions(props.items); validateContent(props.trigger, "Glass menu trigger");
+ const { items, appearance = "icons", layout: arrangement = "fan", direction = "top", distance = 0,
+ triggerSize: requestedTrigger, actionSize: requestedAction, material, motion, connection, refraction,
+ reducedMotion, backdrop, boundary, onReady, onError, onPlaybackUpdate } = props;
+ const triggerSize = boundedSize(requestedTrigger, 72), actionSize = boundedSize(requestedAction, mode === "panel" ? 52 : 56);
+ const labels = appearance === "labels";
+ const measurements = useMenuMeasurements(items, actionSize, labels, triggerSize, boundary);
+ const layout = makeMenuLayout({ mode, trigger: triggerSize, items: measurements.sizes, layout: arrangement,
+ direction, distance, labels, view: measurements.view });
+ const group = useRef(null);
+ const closeButton = useRef(null);
+ const frame = useRef(undefined);
+ const currentLayout = useRef(layout); currentLayout.current = layout;
+ const currentSize = useRef(triggerSize); currentSize.current = triggerSize;
+ const positions = () => items.map(item => {
+ const id = actionSurfaceId(item.id);
+ const target = currentLayout.current.expanded.find(s => s.id === id);
+ const pose = frame.current?.surfaces.find(s => s.id === id);
+ const usePose = pose && pose.width > 1 && pose.height > 1;
+ return { id: item.id, x: (usePose ? pose : target)?.x ?? 0, y: (usePose ? pose : target)?.y ?? 0 };
+ });
+ const behavior = useMenu(props, measurements.root, group, layout.mode === "panel", positions);
+ useImperativeHandle(props.ref, (): GlassMenuHandle => ({
+ get element() { return measurements.root.current; },
+ pause() { group.current?.pause(); },
+ play() { group.current?.play(); },
+ seek(timeMs) { group.current?.seek(timeMs); },
+ replay() { group.current?.replay(); },
+ }), [measurements.root]);
+ useLayoutEffect(() => { measurements.refresh(); }, [behavior.open]);
+ const instanceId = useId(), menuId = `${instanceId}-menu`, triggerId = `${instanceId}-trigger`;
+ const initial = useRef({ surfaces: behavior.open ? layout.expanded : layout.closed, anchor: layout.anchor, open: behavior.open });
+ const panel = layout.mode === "panel";
+ const paint = (next: GlassMorphFrame) => {
+ frame.current = next;
+ const anchor = next.surfaces.find(s => s.id === ANCHOR_ID);
+ if (anchor && group.current?.element) {
+ group.current.element.style.left = `${currentSize.current / 2 - anchor.x}px`;
+ group.current.element.style.top = `${currentSize.current / 2 - anchor.y}px`;
+ const pane = currentLayout.current.expanded.find(s => s.id === TRIGGER_ID);
+ if (pane && closeButton.current) {
+ closeButton.current.style.left = `${anchor.x + pane.x - currentLayout.current.anchor.x + pane.width / 2 - 44}px`;
+ closeButton.current.style.top = `${anchor.y + pane.y - currentLayout.current.anchor.y - pane.height / 2 - 48}px`;
+ }
+ }
+ const triggerPose = next.surfaces.find(s => s.id === TRIGGER_ID);
+ const expanded = currentLayout.current.expanded.find(s => s.id === TRIGGER_ID);
+ if (behavior.scroll.current && triggerPose && expanded && anchor) {
+ behavior.scroll.current.style.opacity = String(panelContentOpacity(
+ { ...triggerPose, x: triggerPose.x - anchor.x, y: triggerPose.y - anchor.y },
+ { ...expanded, x: expanded.x - currentLayout.current.anchor.x, y: expanded.y - currentLayout.current.anchor.y },
+ currentSize.current, behavior.open, next.active));
+ }
+ if (behavior.trigger.current && triggerPose) {
+ const scale = currentLayout.current.mode === "split" && triggerPose.width < currentSize.current * 1.4
+ ? triggerPose.width / currentSize.current : 1;
+ behavior.trigger.current.style.transform = `scale(${scale})`;
+ }
+ };
+ const content = (item: GlassAction) => measurements.registerContent(item.id, node)}>
+ {item.content ?? (!labels && item.icon !== undefined ? item.icon : <>{item.icon}{item.label}>)}
+ ;
+ const itemProps = (item: GlassAction) => ({
+ id: `${instanceId}-item-${encodeURIComponent(item.id)}`,
+ role: "menuitem" as const,
+ type: "button" as const,
+ "aria-label": item.label,
+ disabled: item.disabled,
+ tabIndex: behavior.open && !item.disabled && behavior.focused === item.id ? 0 : -1,
+ className: props.itemClassName,
+ ref: (node: HTMLButtonElement | null) => {
+ if (node) behavior.buttons.current.set(item.id, node); else behavior.buttons.current.delete(item.id);
+ },
+ onFocus: () => behavior.onItemFocus(item.id),
+ onClick: () => behavior.select(item.id),
+ });
+ const triggerProps = {
+ id: triggerId,
+ ref: behavior.trigger,
+ type: "button" as const,
+ "aria-label": props.label,
+ "aria-haspopup": "menu" as const,
+ "aria-expanded": behavior.open,
+ "aria-controls": menuId,
+ disabled: behavior.disabled,
+ className: props.triggerClassName,
+ tabIndex: behavior.triggerTabIndex,
+ onClick: behavior.onTriggerClick,
+ onKeyDown: behavior.onTriggerKeyDown,
+ };
+ const closeIcon = ;
+ const triggerContent = props.trigger ?? (behavior.open && !panel ? closeIcon :
+
+
+ );
+ const pane = layout.expanded.find(s => s.id === TRIGGER_ID)!;
+ return
+ {createElement("style", { href: "react-glaze-menus-v1", precedence: "react-glaze" }, menuStyles)}
+
+ {panel ? : <>
+
+
+ >}
+ {panel && {closeIcon} }
+
+ {triggerContent}
+
;
+}
diff --git a/packages/react/src/morph/menu/layout.ts b/packages/react/src/morph/menu/layout.ts
new file mode 100644
index 0000000..2669a46
--- /dev/null
+++ b/packages/react/src/morph/menu/layout.ts
@@ -0,0 +1,134 @@
+import type { GlassActionLayout, GlassDirection, GlassSurface } from "../../morph-types.js";
+import { arrangementDirection, makeSplitLayout } from "../split-layout.js";
+import type { Size, Surface } from "../model.js";
+
+export const ANCHOR_ID = "glaze:anchor";
+export const TRIGGER_ID = "glaze:trigger";
+export const actionSurfaceId = (id: string) => `glaze:action:${id}`;
+const MARGIN = 8, PADDING = 12, MAX_OVERSCAN = 160;
+const directions: GlassDirection[] = ["top", "right", "bottom", "left"];
+const opposite = { top: "bottom", right: "left", bottom: "top", left: "right" } as const;
+
+export interface ViewportBox { left: number; top: number; right: number; bottom: number }
+export interface MenuItemSize extends Size { id: string }
+export interface MenuLayout {
+ mode: "split" | "panel";
+ direction: GlassDirection;
+ wrapped: boolean;
+ width: number;
+ height: number;
+ anchor: { x: number; y: number };
+ closed: GlassSurface[];
+ expanded: GlassSurface[];
+ panel: Size & { columns: number; contentHeight: number; contentWidth: number };
+}
+
+function fittingViewport(view: ViewportBox, trigger: number): ViewportBox {
+ const half = trigger / 2;
+ if (view.left < half && view.right > -half && view.top < half && view.bottom > -half) return view;
+ // An offscreen anchor must not pull its future menu back onto the visible page.
+ // Reserve overscan inside the group's public 16384px dimension limit.
+ const maximum = 16384 - MAX_OVERSCAN * 2;
+ const width = Math.max(1, Math.min(maximum, view.right - view.left));
+ const height = Math.max(1, Math.min(maximum, view.bottom - view.top));
+ return { left: -width / 2, right: width / 2, top: -height / 2, bottom: height / 2 };
+}
+
+function extents(surfaces: Surface[]) {
+ return {
+ left: Math.min(...surfaces.map(s => s.x - s.w / 2)), right: Math.max(...surfaces.map(s => s.x + s.w / 2)),
+ top: Math.min(...surfaces.map(s => s.y - s.h / 2)), bottom: Math.max(...surfaces.map(s => s.y + s.h / 2)),
+ };
+}
+
+function fits(surfaces: Surface[], view: ViewportBox) {
+ return surfaces.every(s => s.x - s.w / 2 >= view.left + MARGIN - .01 &&
+ s.x + s.w / 2 <= view.right - MARGIN + .01 && s.y - s.h / 2 >= view.top + MARGIN - .01 &&
+ s.y + s.h / 2 <= view.bottom - MARGIN + .01);
+}
+
+function clearTrigger(surface: Surface, trigger: Surface) {
+ return Math.abs(surface.x) >= (surface.w + trigger.w) / 2 + 8 - .01 ||
+ Math.abs(surface.y) >= (surface.h + trigger.h) / 2 + 8 - .01;
+}
+
+function anchorSplit(surfaces: Surface[], direction: GlassDirection, view: ViewportBox) {
+ const origin = surfaces[0];
+ let placed = surfaces.map((s, index) => ({ ...s, id: index ? s.id : TRIGGER_ID, x: s.x - origin.x, y: s.y - origin.y }));
+ if (fits(placed.slice(1), view)) return placed;
+ const actions = placed.slice(1), bounds = extents(actions);
+ const cross = direction === "top" || direction === "bottom";
+ const min = cross ? view.left + MARGIN - bounds.left : view.top + MARGIN - bounds.top;
+ const max = cross ? view.right - MARGIN - bounds.right : view.bottom - MARGIN - bounds.bottom;
+ if (min > max) return undefined;
+ const shift = Math.max(min, Math.min(max, 0));
+ placed = placed.map((s, index) => index ? { ...s, x: s.x + (cross ? shift : 0), y: s.y + (cross ? 0 : shift) } : s);
+ return fits(placed.slice(1), view) && placed.slice(1).every(s => clearTrigger(s, placed[0])) ? placed : undefined;
+}
+
+function split(items: MenuItemSize[], trigger: number, layout: GlassActionLayout, preferred: GlassDirection,
+ distance: number, view: ViewportBox) {
+ const first = arrangementDirection(layout, preferred);
+ const choices = [first, opposite[first], ...directions.filter(d => d !== first && d !== opposite[first])];
+ for (const candidate of choices) {
+ const arrangement = layout === "fan" ? "fan" : candidate === "left" || candidate === "right" ? "row" : "column";
+ const result = makeSplitLayout({ w: 32768, h: 32768 }, trigger, items, arrangement, candidate, distance);
+ const surfaces = anchorSplit(result.surfaces, result.direction, view);
+ if (surfaces) return { surfaces, direction: result.direction, wrapped: candidate !== first && arrangement !== layout };
+ }
+ // Let the core compact or wrap in actual available space, then anchor-check the result.
+ const size = { w: Math.max(0, view.right - view.left), h: Math.max(0, view.bottom - view.top) };
+ for (const candidate of choices) {
+ const result = makeSplitLayout(size, trigger, items, layout, candidate, distance);
+ const surfaces = anchorSplit(result.surfaces, result.direction, view);
+ if (surfaces) return { surfaces, direction: result.direction, wrapped: result.wrapped };
+ }
+ return undefined;
+}
+
+function panel(items: MenuItemSize[], trigger: number, labels: boolean, preferred: GlassDirection, view: ViewportBox) {
+ const availableWidth = Math.max(1, view.right - view.left - MARGIN * 2);
+ const availableHeight = Math.max(1, view.bottom - view.top - MARGIN * 2 - 48);
+ const cellWidth = Math.max(44, ...items.map(s => s.w));
+ const cellHeight = Math.max(44, ...items.map(s => s.h));
+ const columns = labels ? 1 : Math.max(1, Math.min(items.length || 1,
+ Math.floor(Math.max(1, availableWidth - PADDING * 2) / cellWidth)));
+ const contentWidth = columns * cellWidth, contentHeight = Math.ceil(items.length / columns) * cellHeight;
+ const w = Math.min(availableWidth, contentWidth + PADDING * 2);
+ const h = Math.min(availableHeight, contentHeight + PADDING * 2);
+ const minX = view.left + MARGIN + w / 2, maxX = view.right - MARGIN - w / 2;
+ const minY = view.top + MARGIN + 48 + h / 2, maxY = view.bottom - MARGIN - h / 2;
+ const choices = [preferred, opposite[preferred], ...directions.filter(d => d !== preferred && d !== opposite[preferred])];
+ const candidates = choices.map(direction => {
+ const intendedX = direction === "left" ? -(w - trigger) / 2 : direction === "right" ? (w - trigger) / 2 : 0;
+ const intendedY = direction === "top" ? -(h - trigger) / 2 : direction === "bottom" ? (h - trigger) / 2 : 0;
+ const x = Math.max(minX, Math.min(maxX, intendedX)), y = Math.max(minY, Math.min(maxY, intendedY));
+ return { direction, x, y, correction: Math.abs(x - intendedX) + Math.abs(y - intendedY) };
+ });
+ // Prefer the requested growth direction, flipping when it avoids clipping the panel.
+ candidates.sort((a, b) => a.correction - b.correction);
+ const { direction, x, y } = candidates[0];
+ return { surface: { id: TRIGGER_ID, x, y, w, h, r: labels || columns < items.length ? 30 : h / 2 },
+ direction, size: { w, h, columns, contentWidth, contentHeight } };
+}
+
+export function makeMenuLayout(options: { mode: "split" | "panel"; items: MenuItemSize[]; trigger: number;
+ layout: GlassActionLayout; direction: GlassDirection; distance: number; labels: boolean; view: ViewportBox }): MenuLayout {
+ const { items, trigger, direction } = options;
+ const view = fittingViewport(options.view, trigger);
+ const arranged = options.mode === "split" && items.length ? split(items, trigger, options.layout, direction, options.distance, view) : undefined;
+ const pane = panel(items, trigger, options.labels, direction, view);
+ const mode = options.mode === "split" && (arranged || !items.length) ? "split" : "panel";
+ const expanded = mode === "split" ? arranged?.surfaces ?? [{ id: TRIGGER_ID, x: 0, y: 0, w: trigger, h: trigger, r: trigger / 2 }] : [pane.surface];
+ const closed = expanded.map((s, index) => ({ ...s, x: 0, y: 0, w: index ? 0 : trigger, h: index ? 0 : trigger, r: index ? 0 : trigger / 2 }));
+ const bounds = extents([...expanded, ...closed]);
+ // Both states share bounds. The invisible marker only moves when fitting/settings change.
+ const overscan = Math.max(24, Math.min(MAX_OVERSCAN, Math.max(bounds.right - bounds.left, bounds.bottom - bounds.top) * .22));
+ const anchor = { x: overscan - bounds.left, y: overscan - bounds.top };
+ const convert = (surfaces: Surface[]): GlassSurface[] => [...surfaces.map(s => ({ id: s.id,
+ x: s.x + anchor.x, y: s.y + anchor.y, width: s.w, height: s.h, radius: s.r })),
+ { id: ANCHOR_ID, ...anchor, width: 0, height: 0 }];
+ return { mode, direction: arranged?.direction ?? pane.direction, wrapped: arranged?.wrapped ?? options.mode === "split",
+ width: bounds.right - bounds.left + overscan * 2, height: bounds.bottom - bounds.top + overscan * 2,
+ anchor, closed: convert(closed), expanded: convert(expanded), panel: pane.size };
+}
diff --git a/packages/react/src/morph/menu/logic.ts b/packages/react/src/morph/menu/logic.ts
new file mode 100644
index 0000000..6786179
--- /dev/null
+++ b/packages/react/src/morph/menu/logic.ts
@@ -0,0 +1,152 @@
+import { Children, isValidElement } from "react";
+import type { ReactNode } from "react";
+import type { GlassAction, GlassSurface } from "../../morph-types.js";
+
+export function validateActions(items: readonly GlassAction[]) {
+ if (!Array.isArray(items)) throw new TypeError("Glass menu items must be an array of actions.");
+ if (items.length > 6) throw new RangeError("Glass menus support at most 6 actions. Use a larger menu for longer lists.");
+ const ids = new Set();
+ for (const item of items) {
+ if (!item || typeof item.id !== "string" || !item.id.trim() || ids.has(item.id))
+ throw new TypeError("Glass menu action IDs must be nonempty, unique strings.");
+ if (typeof item.label !== "string" || !item.label.trim())
+ throw new TypeError(`Glass menu action '${item.id}' needs a nonempty label.`);
+ if (item.onSelect !== undefined && typeof item.onSelect !== "function")
+ throw new TypeError(`Glass menu action '${item.id}' onSelect must be a function.`);
+ ids.add(item.id);
+ validateContent(item.content ?? item.icon, `Action '${item.id}'`);
+ }
+}
+
+/** Native controls are supplied by the menu; content slots must not nest controls. */
+export function validateContent(content: ReactNode, name: string) {
+ Children.forEach(content, child => {
+ if (!isValidElement<{ children?: ReactNode; href?: string; tabIndex?: number; contentEditable?: unknown }>(child)) return;
+ if (typeof child.type === "string" && (["button", "input", "select", "textarea", "summary"].includes(child.type) ||
+ (child.type === "a" && child.props.href !== undefined) || child.props.contentEditable === true ||
+ (child.props.tabIndex !== undefined && child.props.tabIndex >= 0)))
+ throw new TypeError(`${name} must contain non-interactive content. The glass menu supplies the native button.`);
+ validateContent(child.props.children, name);
+ });
+}
+
+export function edgeItem(items: readonly GlassAction[], edge: "first" | "last") {
+ const enabled = items.filter(item => !item.disabled);
+ return (edge === "first" ? enabled[0] : enabled.at(-1))?.id;
+}
+
+export function adjacentItem(items: readonly GlassAction[], current: string | undefined, step: number) {
+ const enabled = items.filter(item => !item.disabled);
+ if (!enabled.length) return undefined;
+ const index = enabled.findIndex(item => item.id === current);
+ if (index < 0) return enabled[step < 0 ? enabled.length - 1 : 0].id;
+ return enabled[(index + step + enabled.length) % enabled.length].id;
+}
+
+export interface MenuPoint { id: string; x: number; y: number }
+export function directionalItem(points: readonly MenuPoint[], current: string | undefined, key: string) {
+ const direction = ({ ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] } as
+ Record)[key];
+ const origin = points.find(point => point.id === current);
+ if (!direction || !origin) return current;
+ let closest = current, score = Infinity;
+ for (const point of points) {
+ const dx = point.x - origin.x, dy = point.y - origin.y;
+ const forward = dx * direction[0] + dy * direction[1];
+ if (point.id === current || forward <= 1) continue;
+ const candidate = forward + Math.abs(dx * direction[1] - dy * direction[0]) * 3;
+ if (candidate < score) { closest = point.id; score = candidate; }
+ }
+ return closest;
+}
+
+export interface TypeaheadState { text: string; time: number }
+export function typeaheadItem(items: readonly GlassAction[], current: string | undefined,
+ key: string, time: number, previous: TypeaheadState) {
+ const text = `${time - previous.time > 500 ? "" : previous.text}${key}`.toLocaleLowerCase();
+ const characters = [...text];
+ const query = characters.every(letter => letter === characters[0]) ? characters[0] : text;
+ const enabled = items.filter(item => !item.disabled);
+ const from = enabled.findIndex(item => item.id === current);
+ const order = [...enabled.slice(from + 1), ...enabled.slice(0, from + 1)];
+ return { id: order.find(item => item.label.trim().toLocaleLowerCase().startsWith(query))?.id,
+ state: { text, time } };
+}
+
+export function boundedSize(value: number | undefined, fallback: number) {
+ return value !== undefined && Number.isFinite(value) ? Math.max(44, Math.min(100, value)) : fallback;
+}
+
+type PanelPose = Pick;
+export function panelContentOpacity(pose: PanelPose, expanded: PanelPose, triggerSize: number, open: boolean, active: boolean) {
+ if (!active) return Number(open);
+ const travel = expanded.x ** 2 + expanded.y ** 2;
+ const width = expanded.width - triggerSize, height = expanded.height - triggerSize;
+ const growth = width ** 2 + height ** 2;
+ // Position excludes the trigger's scale pulse, including almost trigger-sized panels.
+ const progress = travel > .01 ? (pose.x * expanded.x + pose.y * expanded.y) / travel :
+ growth > .01 ? ((pose.width - triggerSize) * width + (pose.height - triggerSize) * height) / growth : Number(open);
+ return Math.max(0, Math.min(1, (progress - .2) / .6));
+}
+
+interface PageTabStop {
+ value: T;
+ radio?: { name: string; checked: boolean; form: object | null; tree: object };
+}
+export function radioTabStops(candidates: readonly PageTabStop[], backwards: boolean): T[] {
+ return candidates.filter(candidate => {
+ const radio = candidate.radio;
+ if (!radio?.name) return true;
+ const group = candidates.filter(other => other.radio?.name === radio.name &&
+ other.radio.form === radio.form && other.radio.tree === radio.tree);
+ const selected = group.find(other => other.radio?.checked) ?? (backwards ? group.at(-1) : group[0]);
+ return selected === candidate;
+ }).map(candidate => candidate.value);
+}
+
+export function isMenuInteraction(
+ path: readonly EventTarget[],
+ root: HTMLElement | null,
+ external: HTMLElement | null = null,
+) {
+ const ownerDocument = external?.ownerDocument;
+ const associated = external === ownerDocument?.body ||
+ external === ownerDocument?.documentElement ? null : external;
+ return [root, associated].some(boundary => boundary && path.some(target => {
+ if (target === boundary) return true;
+ if (!target || typeof target !== "object") return false;
+ try { return boundary.contains(target as Node); }
+ catch { return false; }
+ }));
+}
+
+/**
+ * A controlled menu can be opened by an associated toolbar without passing
+ * through the menu's own request handler. Keep that toolbar's focus in place,
+ * while an explicit trigger request still directs focus into the menu.
+ */
+export function openingFocusTarget(
+ pending: "first" | "last" | string | undefined,
+ active: EventTarget | null,
+ external: HTMLElement | null,
+) {
+ if (pending !== undefined) return pending;
+ return isMenuInteraction(active ? [active] : [], null, external)
+ ? undefined
+ : "first";
+}
+
+export class MenuFocusState {
+ owned = false;
+ private closing: "return" | "outside" | "tab" | undefined;
+
+ enter() { this.owned = true; this.cancelClose(); }
+ associate() { this.owned = false; this.cancelClose(); }
+ leave() { this.owned = false; }
+ requestClose(reason: "return" | "outside" | "tab") { this.closing = reason; }
+ cancelClose() { this.closing = undefined; }
+ shouldRestore(activeInside: boolean) {
+ return (this.closing ?? "return") === "return" && (this.owned || activeInside);
+ }
+ closed() { this.owned = false; this.cancelClose(); }
+}
diff --git a/packages/react/src/morph/menu/styles.ts b/packages/react/src/morph/menu/styles.ts
new file mode 100644
index 0000000..6f36838
--- /dev/null
+++ b/packages/react/src/morph/menu/styles.ts
@@ -0,0 +1,23 @@
+export const menuStyles = `
+[data-glaze-menu-root]{box-sizing:border-box;vertical-align:middle}
+[data-glaze-menu-root] [data-glaze-group]{pointer-events:none}
+[data-glaze-menu-root] [data-glaze-surface][inert]{pointer-events:none!important}
+[data-glaze-menu-item-content]{display:inline-flex;align-items:center;justify-content:center;gap:10px;flex-shrink:0;white-space:nowrap;width:max-content;max-width:none}
+[data-glaze-menu-item-content]>svg{flex-shrink:0}
+[data-glaze-menu-panel] [data-glaze-content]{width:100%;height:100%}
+[data-glaze-menu-scroll]{position:relative;box-sizing:border-box;overflow:auto;overscroll-behavior:contain;padding:12px;width:100%;height:100%;pointer-events:auto;scrollbar-width:thin}
+[data-glaze-menu-grid]{position:relative;display:grid;width:max-content;min-width:100%}
+[data-glaze-menu-panel-item]{appearance:none;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border:0;background:transparent;color:inherit;font:inherit;padding:8px 16px;min-width:44px;min-height:44px;border-radius:17px;cursor:pointer;text-align:start}
+[data-glaze-menu-panel-item]:disabled{cursor:default;opacity:.4}
+[data-glaze-menu-panel][data-appearance="labels"] [data-glaze-menu-panel-item]{justify-content:flex-start}
+[data-glaze-menu-panel-item]:focus-visible{outline:auto;outline-offset:-3px}
+[data-glaze-menu-trigger-overlay]{appearance:none;position:absolute;inset:0;width:100%;height:100%;box-sizing:border-box;display:flex;align-items:center;justify-content:center;border:0;border-radius:50%;padding:0;background:transparent;color:inherit;font:inherit;cursor:pointer;z-index:2}
+[data-glaze-menu-trigger-overlay]:disabled{cursor:default;opacity:.4}
+[data-glaze-menu-trigger-overlay]:focus-visible{outline:auto;outline-offset:3px}
+[data-glaze-menu-trigger-overlay][inert]{visibility:hidden;pointer-events:none}
+[data-glaze-menu-close]{appearance:none;position:absolute;box-sizing:border-box;display:flex;align-items:center;justify-content:center;width:44px;height:44px;border:0;padding:0;border-radius:50%;background:color-mix(in srgb,Canvas 65%,transparent);color:inherit;cursor:pointer;pointer-events:auto;z-index:2}
+[data-glaze-menu-close]:focus-visible{outline:auto;outline-offset:2px}
+[data-glaze-menu-panel][inert] [data-glaze-menu-scroll]{pointer-events:none}
+@media(hover:hover){[data-glaze-menu-panel-item]:enabled:hover{background:color-mix(in srgb,currentColor 8%,transparent)}}
+@media(forced-colors:active){[data-glaze-menu-panel-item]:focus-visible{outline:2px solid Highlight}}
+`;
diff --git a/packages/react/src/morph/menu/use-measurements.ts b/packages/react/src/morph/menu/use-measurements.ts
new file mode 100644
index 0000000..ca99de3
--- /dev/null
+++ b/packages/react/src/morph/menu/use-measurements.ts
@@ -0,0 +1,130 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import type { RefObject } from "react";
+import type { GlassAction } from "../../morph-types.js";
+import { actionSurfaceId, type MenuItemSize, type ViewportBox } from "./layout.js";
+
+interface Measurements { sizes: MenuItemSize[]; view: ViewportBox }
+type Rect = Pick;
+type BoundaryObserver = Pick;
+
+export function menuViewFromRects(
+ trigger: Rect,
+ triggerSize: number,
+ viewport: Rect,
+ boundary?: Rect,
+): ViewportBox {
+ const bounds = boundary && boundary.width > 0 && boundary.height > 0 ? boundary : viewport;
+ const x = trigger.left + trigger.width / 2, y = trigger.top + trigger.height / 2;
+ const scaleX = trigger.width / triggerSize || 1, scaleY = trigger.height / triggerSize || 1;
+ return {
+ left: (bounds.left - x) / scaleX,
+ top: (bounds.top - y) / scaleY,
+ right: (bounds.right - x) / scaleX,
+ bottom: (bounds.bottom - y) / scaleY,
+ };
+}
+
+export function rebindBoundaryObserver(
+ observer: BoundaryObserver | undefined,
+ observed: HTMLElement | null,
+ boundary?: RefObject,
+) {
+ const next = boundary?.current ?? null;
+ if (next === observed) return observed;
+ if (observed) observer?.unobserve(observed);
+ if (next) observer?.observe(next);
+ return next;
+}
+
+export function useMenuMeasurements(
+ items: readonly GlassAction[],
+ size: number,
+ labels: boolean,
+ triggerSize: number,
+ boundary?: RefObject,
+) {
+ const root = useRef(null);
+ const contents = useRef(new Map());
+ const resizeObserver = useRef(undefined);
+ const boundaryObserver = useRef(undefined);
+ const observedBoundary = useRef(null);
+ const refresh = useRef<() => void>(() => {});
+ const latest = useRef({ items, size, labels, triggerSize, boundary });
+ latest.current = { items, size, labels, triggerSize, boundary };
+ const [measured, setMeasured] = useState();
+ const signature = JSON.stringify(items.map(item => [item.id, item.label, item.icon !== undefined, item.content !== undefined]));
+ useLayoutEffect(() => {
+ let disposed = false;
+ const measure = () => {
+ if (disposed || !root.current) return;
+ const current = latest.current, box = root.current.getBoundingClientRect();
+ const viewport = window.visualViewport;
+ const left = viewport?.offsetLeft ?? 0, top = viewport?.offsetTop ?? 0;
+ const width = viewport?.width ?? document.documentElement.clientWidth;
+ const height = viewport?.height ?? document.documentElement.clientHeight;
+ const viewportRect = { left, top, right: left + width, bottom: top + height, width, height };
+ const boundaryElement = rebindBoundaryObserver(
+ boundaryObserver.current,
+ observedBoundary.current,
+ current.boundary,
+ );
+ observedBoundary.current = boundaryElement;
+ const boundaryRect = boundaryElement?.isConnected ? boundaryElement.getBoundingClientRect() : undefined;
+ const view = menuViewFromRects(box, current.triggerSize, viewportRect, boundaryRect);
+ const sizes = current.items.map(item => {
+ const content = contents.current.get(item.id);
+ const padding = current.labels || item.content !== undefined || item.icon === undefined ? 32 : 0;
+ return { id: actionSurfaceId(item.id), w: Math.max(current.size, Math.ceil(content?.offsetWidth ?? 0) + padding),
+ h: Math.max(current.size, Math.ceil(content?.offsetHeight ?? 0) + 16) };
+ });
+ const next = { sizes, view };
+ setMeasured(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);
+ };
+ const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(measure);
+ const boundaryResizeObserver = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(measure);
+ refresh.current = measure;
+ resizeObserver.current = observer;
+ boundaryObserver.current = boundaryResizeObserver;
+ if (root.current) observer?.observe(root.current);
+ for (const content of contents.current.values()) observer?.observe(content);
+ const viewport = window.visualViewport;
+ window.addEventListener("resize", measure);
+ window.addEventListener("scroll", measure, true);
+ viewport?.addEventListener("resize", measure);
+ viewport?.addEventListener("scroll", measure);
+ const fonts = document.fonts;
+ fonts?.addEventListener("loadingdone", measure);
+ void fonts?.ready.then(measure);
+ measure();
+ return () => {
+ disposed = true; observer?.disconnect();
+ if (resizeObserver.current === observer) resizeObserver.current = undefined;
+ boundaryResizeObserver?.disconnect();
+ if (boundaryObserver.current === boundaryResizeObserver) boundaryObserver.current = undefined;
+ observedBoundary.current = null;
+ window.removeEventListener("resize", measure);
+ window.removeEventListener("scroll", measure, true);
+ viewport?.removeEventListener("resize", measure);
+ viewport?.removeEventListener("scroll", measure);
+ fonts?.removeEventListener("loadingdone", measure);
+ };
+ }, [signature, size, labels, triggerSize, boundary]);
+ useLayoutEffect(() => {
+ if (observedBoundary.current !== boundary?.current) refresh.current();
+ });
+ const byId = new Map(measured?.sizes.map(item => [item.id, item]));
+ return { root,
+ refresh: () => refresh.current(),
+ registerContent(id: string, node: HTMLSpanElement | null) {
+ const previous = contents.current.get(id);
+ if (previous) resizeObserver.current?.unobserve(previous);
+ if (node) { contents.current.set(id, node); resizeObserver.current?.observe(node); }
+ else contents.current.delete(id);
+ },
+ sizes: items.map(item => byId.get(actionSurfaceId(item.id)) ?? {
+ id: actionSurfaceId(item.id), w: labels || (!item.icon && !item.content) ? Math.max(size, item.label.length * 8 + 32) : size,
+ h: size,
+ }),
+ // Server rendering has no viewport. This is replaced before the first browser paint.
+ view: measured?.view ?? { left: -512, right: 512, top: -384, bottom: 384 } };
+}
diff --git a/packages/react/src/morph/menu/use-menu.ts b/packages/react/src/morph/menu/use-menu.ts
new file mode 100644
index 0000000..906bae2
--- /dev/null
+++ b/packages/react/src/morph/menu/use-menu.ts
@@ -0,0 +1,204 @@
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import type { KeyboardEvent, RefObject } from "react";
+import type { GlassGroupHandle, GlassMenuBaseProps } from "../../morph-types.js";
+import { adjacentItem, directionalItem, edgeItem, isMenuInteraction, MenuFocusState, openingFocusTarget, radioTabStops, typeaheadItem, type MenuPoint } from "./logic.js";
+import { TRIGGER_ID } from "./layout.js";
+
+type CloseReason = "return" | "outside" | "tab";
+const tabbable = "a[href],area[href],button,input,select,textarea,summary,iframe,[tabindex],[contenteditable='true']";
+
+function adjacentPageControl(root: HTMLElement, trigger: HTMLButtonElement, backwards: boolean) {
+ const candidates = [...root.ownerDocument.querySelectorAll(tabbable)].filter(node => node === trigger ||
+ (!root.contains(node) && node.tabIndex >= 0 && !node.matches(":disabled") && !node.closest("[inert],[hidden]") &&
+ node.getClientRects().length > 0 && getComputedStyle(node).visibility !== "hidden"));
+ if (!candidates.includes(trigger)) candidates.push(trigger);
+ candidates.sort((a, b) => {
+ const ai = Math.max(0, a.tabIndex), bi = Math.max(0, b.tabIndex);
+ if (ai !== bi && (ai > 0 || bi > 0)) return (ai || Infinity) - (bi || Infinity);
+ return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
+ });
+ const stops = radioTabStops(candidates.map(node => {
+ const input = node.tagName === "INPUT" ? node as HTMLInputElement : undefined;
+ return { value: node, radio: input?.type === "radio" ? {
+ name: input.name, checked: input.checked, form: input.form, tree: input.getRootNode(),
+ } : undefined };
+ }), backwards);
+ return stops[stops.indexOf(trigger) + (backwards ? -1 : 1)];
+}
+
+export function useMenu(props: GlassMenuBaseProps, root: RefObject,
+ group: RefObject, panel: boolean, positions: () => MenuPoint[]) {
+ const [internalOpen, setInternalOpen] = useState(props.defaultOpen ?? false);
+ const [focused, setFocused] = useState(() => edgeItem(props.items, "first"));
+ const [tabExiting, setTabExiting] = useState(false);
+ const disabled = !!props.disabled || props.items.length === 0;
+ const open = !disabled && (props.open ?? internalOpen);
+ const trigger = useRef(null), scroll = useRef(null);
+ const buttons = useRef(new Map());
+ const pendingFocus = useRef<"first" | "last" | string | undefined>(undefined);
+ const focus = useRef(new MenuFocusState());
+ const previousOpen = useRef(false), typeahead = useRef({ text: "", time: -Infinity });
+ const previousPanel = useRef(panel);
+ const tabTimer = useRef | undefined>(undefined);
+ const latest = useRef({ props, open, panel, focused, positions });
+ latest.current = { props, open, panel, focused, positions };
+
+ const focusTrigger = () => {
+ if (trigger.current && !trigger.current.disabled) trigger.current.focus({ preventScroll: true });
+ else if (root.current && trigger.current) (adjacentPageControl(root.current, trigger.current, false) ?? root.current).focus({ preventScroll: true });
+ };
+ const focusEmptyMenu = () => root.current?.querySelector("[role='menu']")?.focus({ preventScroll: true });
+ const focusItem = (id: string | undefined) => {
+ const item = latest.current.props.items.find(action => action.id === id && !action.disabled);
+ const button = item && buttons.current.get(item.id);
+ if (!button || !latest.current.open) return;
+ setFocused(item.id); button.focus({ preventScroll: true });
+ const container = scroll.current;
+ if (container) {
+ let top = button.offsetTop, left = button.offsetLeft;
+ let parent = button.offsetParent as HTMLElement | null;
+ while (parent && parent !== container) {
+ top += parent.offsetTop; left += parent.offsetLeft;
+ parent = parent.offsetParent as HTMLElement | null;
+ }
+ if (top < container.scrollTop) container.scrollTop = top;
+ else if (top + button.offsetHeight > container.scrollTop + container.clientHeight)
+ container.scrollTop = top + button.offsetHeight - container.clientHeight;
+ if (left < container.scrollLeft) container.scrollLeft = left;
+ else if (left + button.offsetWidth > container.scrollLeft + container.clientWidth)
+ container.scrollLeft = left + button.offsetWidth - container.clientWidth;
+ }
+ };
+ const requestOpen = (next: boolean, reason: CloseReason = "return", edge?: "first" | "last") => {
+ const current = latest.current;
+ if (next && (current.props.disabled || !current.props.items.length)) return;
+ if (next) { pendingFocus.current = edge ?? "first"; focus.current.cancelClose(); }
+ else { pendingFocus.current = undefined; focus.current.requestClose(reason); }
+ if (next === current.open) {
+ if (next && edge) focusItem(edgeItem(current.props.items, edge));
+ return;
+ }
+ if (current.props.open === undefined) setInternalOpen(next);
+ current.props.onOpenChange?.(next);
+ };
+ const requestRef = useRef(requestOpen); requestRef.current = requestOpen;
+
+ useLayoutEffect(() => {
+ const wasOpen = previousOpen.current;
+ const changedPanel = previousPanel.current !== panel;
+ previousOpen.current = open;
+ previousPanel.current = panel;
+ if (wasOpen !== open) {
+ group.current?.pulse(TRIGGER_ID, open ? "compress" : "expand");
+ typeahead.current = { text: "", time: -Infinity };
+ if (open) {
+ const target = openingFocusTarget(
+ pendingFocus.current,
+ root.current?.ownerDocument.activeElement ?? null,
+ props.externalControls?.current ?? null,
+ );
+ if (target !== undefined) {
+ const id = target === "first" || target === "last" ? edgeItem(props.items, target) : target;
+ if (id) focusItem(id);
+ else if (panel) focusEmptyMenu();
+ }
+ pendingFocus.current = undefined;
+ } else {
+ const active = root.current?.ownerDocument.activeElement;
+ if (focus.current.shouldRestore(root.current?.contains(active ?? null) ?? false))
+ focusTrigger();
+ focus.current.closed();
+ }
+ }
+ if (open && changedPanel && focus.current.owned) {
+ if (focused) focusItem(focused); else if (panel) focusEmptyMenu();
+ }
+ if (open && !props.items.some(item => item.id === focused && !item.disabled)) {
+ const id = edgeItem(props.items, "first");
+ setFocused(id);
+ const active = root.current?.ownerDocument.activeElement;
+ if (focus.current.owned && (!active || active === document.body || root.current?.contains(active))) {
+ if (id) focusItem(id); else if (panel) focusEmptyMenu(); else focusTrigger();
+ }
+ }
+ }, [open, panel, focused, props.items, root, group]);
+
+ useEffect(() => {
+ if (!open) return;
+ const doc = root.current?.ownerDocument;
+ if (!doc) return;
+ const outside = (event: PointerEvent) => {
+ if (!isMenuInteraction(event.composedPath(), root.current,
+ latest.current.props.externalControls?.current ?? null))
+ requestRef.current(false, "outside");
+ };
+ const focusChanged = (event: FocusEvent) => {
+ const path = event.composedPath();
+ const inside = isMenuInteraction(path, root.current);
+ const associated = isMenuInteraction(path, null,
+ latest.current.props.externalControls?.current ?? null);
+ if (inside) focus.current.enter();
+ else if (associated) focus.current.associate();
+ else { focus.current.leave(); requestRef.current(false, "outside"); }
+ };
+ doc.addEventListener("pointerdown", outside, true);
+ doc.addEventListener("focusin", focusChanged, true);
+ return () => { doc.removeEventListener("pointerdown", outside, true); doc.removeEventListener("focusin", focusChanged, true); };
+ }, [open, root]);
+ useEffect(() => () => { if (tabTimer.current !== undefined) clearTimeout(tabTimer.current); }, []);
+
+ const keyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented || !latest.current.open) return;
+ const current = latest.current;
+ if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); requestOpen(false); return; }
+ if (event.key === "Tab") {
+ const target = root.current && trigger.current ? adjacentPageControl(root.current, trigger.current, event.shiftKey) : undefined;
+ if (target) {
+ event.preventDefault();
+ if (isMenuInteraction([target], null, current.props.externalControls?.current ?? null))
+ focus.current.associate();
+ else requestOpen(false, "tab");
+ target.focus();
+ }
+ else {
+ setTabExiting(true); requestOpen(false, "tab");
+ tabTimer.current = setTimeout(() => setTabExiting(false), 0);
+ }
+ return;
+ }
+ const id = [...buttons.current].find(([, node]) => node === event.target)?.[0] ?? current.focused;
+ let next: string | undefined;
+ if (event.key === "Home" || event.key === "End") next = edgeItem(current.props.items, event.key === "Home" ? "first" : "last");
+ else if (event.key.startsWith("Arrow")) {
+ if (current.panel) {
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
+ next = adjacentItem(current.props.items, id, event.key === "ArrowDown" ? 1 : -1);
+ } else {
+ const enabled = new Set(current.props.items.filter(item => !item.disabled).map(item => item.id));
+ next = directionalItem(current.positions().filter(point => enabled.has(point.id)), id, event.key);
+ }
+ } else if ([...event.key].length === 1 && event.key !== " " && !event.altKey && !event.ctrlKey && !event.metaKey) {
+ const result = typeaheadItem(current.props.items, id, event.key, event.timeStamp, typeahead.current);
+ typeahead.current = result.state; next = result.id;
+ } else return;
+ event.preventDefault(); focusItem(next);
+ };
+
+ return { open, disabled, trigger, buttons, scroll, focused,
+ triggerTabIndex: tabExiting || (panel && open) ? -1 : 0,
+ onFocusCapture() { focus.current.enter(); },
+ onTriggerClick() { requestOpen(!latest.current.open); },
+ onTriggerKeyDown(event: KeyboardEvent) {
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ event.preventDefault(); requestOpen(true, "return", event.key === "ArrowUp" ? "last" : "first");
+ } else keyDown(event);
+ },
+ onMenuKeyDown: keyDown,
+ onItemFocus(id: string) { focus.current.enter(); setFocused(id); },
+ select(id: string) {
+ const item = latest.current.props.items.find(action => action.id === id);
+ if (!latest.current.open || !item || item.disabled) return;
+ try { item.onSelect?.(); } finally { requestOpen(false); }
+ },
+ close() { requestOpen(false); } };
+}
diff --git a/packages/react/src/morph/model.ts b/packages/react/src/morph/model.ts
new file mode 100644
index 0000000..f28443b
--- /dev/null
+++ b/packages/react/src/morph/model.ts
@@ -0,0 +1,20 @@
+export type Direction = 'left' | 'top' | 'right' | 'bottom';
+export type SplitArrangement = 'row' | 'column' | 'fan';
+
+export interface Size {
+ w: number;
+ h: number;
+}
+
+export interface Surface extends Size {
+ id: string;
+ x: number;
+ y: number;
+ r: number;
+}
+
+export const MAX_SURFACES = 8;
+export const clamp = (value: number, minimum = 0, maximum = 1) =>
+ Math.max(minimum, Math.min(maximum, value));
+export const lerp = (from: number, to: number, progress: number) =>
+ from + (to - from) * progress;
diff --git a/packages/react/src/morph/motion-config.ts b/packages/react/src/morph/motion-config.ts
new file mode 100644
index 0000000..ccb4319
--- /dev/null
+++ b/packages/react/src/morph/motion-config.ts
@@ -0,0 +1,4 @@
+export const DEFAULT_DURATION = 500;
+export const DEFAULT_BOUNCE = 0.5;
+export const MIN_DURATION = 100;
+export const MAX_DURATION = 3000;
diff --git a/packages/react/src/morph/motion.ts b/packages/react/src/morph/motion.ts
new file mode 100644
index 0000000..e9a9137
--- /dev/null
+++ b/packages/react/src/morph/motion.ts
@@ -0,0 +1,398 @@
+import { clamp, lerp, type Surface } from './model.js';
+import { DEFAULT_BOUNCE, DEFAULT_DURATION, MAX_DURATION, MIN_DURATION } from './motion-config.js';
+
+export interface Spring {
+ value: number;
+ velocity: number;
+ target: number;
+}
+
+export interface MotionOptions {
+ duration: number;
+ bounce: number;
+}
+
+const keys = ['x', 'y', 'w', 'h', 'r'] as const;
+type MovingSurface = { id: string } & Record;
+type Pulse = {
+ current: Spring;
+ start: Spring;
+ elapsed: number;
+ /** The state immediately before the most recent authored pulse. */
+ replayBase: Spring;
+ /** The latest authored pulse direction, for retiming a checkpoint. */
+ replayImpulse: -1 | 1;
+};
+export interface MotionSnapshot {
+ items: Map;
+ starts: Map;
+ present: Set;
+ openness: Spring;
+ opennessStart: Spring;
+ pulses: Map;
+ options: MotionOptions;
+ geometryElapsed: number;
+ elapsed: number;
+ moving: boolean;
+}
+
+function sampleSpring(spring: Spring, time: number, omega: number, damping: number): Spring {
+ const displacement = spring.value - spring.target;
+ const decay = damping * omega;
+ const envelope = Math.exp(-decay * time);
+ if (damping >= 1) {
+ const b = spring.velocity + omega * displacement;
+ const next = displacement + b * time;
+ return {
+ target: spring.target,
+ value: spring.target + envelope * next,
+ velocity: envelope * (b - omega * next),
+ };
+ }
+ const frequency = omega * Math.sqrt(1 - damping * damping);
+ const b = (spring.velocity + decay * displacement) / frequency;
+ const cosine = Math.cos(frequency * time);
+ const sine = Math.sin(frequency * time);
+ const next = displacement * cosine + b * sine;
+ return {
+ target: spring.target,
+ value: spring.target + envelope * next,
+ velocity: envelope * (-decay * next - displacement * frequency * sine + b * frequency * cosine),
+ };
+}
+
+function timedSpring(start: Spring, elapsed: number, duration: number, bounce: number): Spring {
+ if (elapsed >= duration) return { value: start.target, target: start.target, velocity: 0 };
+ const raw = sampleSpring(start, elapsed, 12 / duration, 1 - bounce * 0.8);
+ const progress = elapsed / duration;
+ // The envelope preserves incoming velocity and reaches rest at the exact deadline.
+ const envelope = (1 - progress * progress) ** 2;
+ const slope = -4 * progress * (1 - progress * progress) / duration;
+ return {
+ target: start.target,
+ value: start.target + (raw.value - start.target) * envelope,
+ velocity: raw.velocity * envelope + (raw.value - start.target) * slope,
+ };
+}
+
+function advanceElapsed(elapsed: number, delta: number, duration: number) {
+ const next = Math.min(duration, elapsed + delta);
+ return next >= duration - 1e-9 ? duration : next;
+}
+
+function normalizedOptions(options: MotionOptions): MotionOptions {
+ return {
+ duration: Number.isFinite(options.duration)
+ ? clamp(options.duration, MIN_DURATION, MAX_DURATION)
+ : DEFAULT_DURATION,
+ bounce: Number.isFinite(options.bounce)
+ ? clamp(options.bounce)
+ : DEFAULT_BOUNCE,
+ };
+}
+
+function pulseVelocity(options: MotionOptions) {
+ return (0.7 + 1.8 * options.bounce) / (options.duration / 1000);
+}
+
+function copyPulse(pulse: Pulse): Pulse {
+ return {
+ current: { ...pulse.current },
+ start: { ...pulse.start },
+ elapsed: pulse.elapsed,
+ replayBase: { ...pulse.replayBase },
+ replayImpulse: pulse.replayImpulse,
+ };
+}
+
+/**
+ * Keeps a recorded transition's authored pulse meaningful after its timing
+ * configuration changes while it is at rest. Live motion still preserves its
+ * current velocity through `configure`.
+ */
+export function retimeSnapshot(
+ snapshot: MotionSnapshot,
+ options: MotionOptions,
+): MotionSnapshot {
+ const next = {
+ ...snapshot,
+ options: normalizedOptions(options),
+ pulses: new Map(
+ [...snapshot.pulses].map(([id, pulse]) => [id, copyPulse(pulse)]),
+ ),
+ };
+ const velocity = pulseVelocity(next.options);
+ for (const pulse of next.pulses.values()) {
+ // Checkpoints are created at a retarget/pulse boundary. If a caller has
+ // changed motion while the recorded transition is resting, restart that
+ // pulse with the newly configured impulse rather than an old duration.
+ if (pulse.elapsed !== 0) continue;
+ pulse.current = {
+ ...pulse.replayBase,
+ velocity: pulse.replayBase.velocity + pulse.replayImpulse * velocity,
+ };
+ pulse.start = { ...pulse.current };
+ }
+ return next;
+}
+
+function normalizedSurfaces(surfaces: Surface[]): Surface[] {
+ const ids = new Set();
+ return surfaces.map(surface => {
+ if (ids.has(surface.id)) throw new RangeError(`Duplicate morph surface ID: ${surface.id}`);
+ if (keys.some(key => !Number.isFinite(surface[key]))) {
+ throw new RangeError(`Morph surface ${surface.id} requires finite geometry.`);
+ }
+ ids.add(surface.id);
+ return { ...surface, w: Math.max(0, surface.w), h: Math.max(0, surface.h), r: Math.max(0, surface.r) };
+ });
+}
+
+function makeItem(surface: Surface): MovingSurface {
+ return {
+ id: surface.id,
+ x: { value: surface.x, velocity: 0, target: surface.x },
+ y: { value: surface.y, velocity: 0, target: surface.y },
+ w: { value: surface.w, velocity: 0, target: surface.w },
+ h: { value: surface.h, velocity: 0, target: surface.h },
+ r: { value: surface.r, velocity: 0, target: surface.r },
+ };
+}
+
+function copyItem(item: MovingSurface): MovingSurface {
+ return {
+ id: item.id,
+ x: { ...item.x },
+ y: { ...item.y },
+ w: { ...item.w },
+ h: { ...item.h },
+ r: { ...item.r },
+ };
+}
+
+export class MotionState {
+ readonly items = new Map();
+ readonly openness: Spring = { value: 0, velocity: 0, target: 0 };
+ private starts = new Map();
+ private present = new Set();
+ private opennessStart = { ...this.openness };
+ private pulses = new Map();
+ private options: MotionOptions = { duration: DEFAULT_DURATION, bounce: DEFAULT_BOUNCE };
+ private geometryElapsed = 0;
+ private elapsed = 0;
+ private moving = false;
+
+ configure(options: MotionOptions) {
+ const next = normalizedOptions(options);
+ const changed = next.duration !== this.options.duration || next.bounce !== this.options.bounce;
+ this.options = next;
+ if (!changed) return false;
+ if (this.moving) this.begin();
+ for (const pulse of this.pulses.values()) {
+ pulse.start = { ...pulse.current };
+ pulse.elapsed = 0;
+ }
+ if (this.active) this.elapsed = 0;
+ else this.elapsed = this.options.duration / 1000;
+ return true;
+ }
+
+ private begin() {
+ this.starts.clear();
+ for (const item of this.items.values()) this.starts.set(item.id, copyItem(item));
+ this.opennessStart = { ...this.openness };
+ this.geometryElapsed = 0;
+ this.elapsed = 0;
+ this.moving = this.openness.value !== this.openness.target || this.openness.velocity !== 0 ||
+ [...this.items.values()].some(item => keys.some(key =>
+ item[key].value !== item[key].target || item[key].velocity !== 0));
+ if (!this.moving) this.pruneRemoved();
+ }
+
+ retarget(surfaces: Surface[], open = true) {
+ const next = normalizedSurfaces(surfaces);
+ let changed = this.openness.target !== Number(open);
+ this.openness.target = Number(open);
+ this.present = new Set(next.map(surface => surface.id));
+ for (const [id, item] of this.items) {
+ if (this.present.has(id)) continue;
+ this.pulses.delete(id);
+ for (const key of ['w', 'h', 'r'] as const) {
+ if (item[key].target !== 0) changed = true;
+ item[key].target = 0;
+ }
+ }
+ for (const surface of next) {
+ let item = this.items.get(surface.id);
+ if (!item) {
+ item = makeItem(surface);
+ this.items.set(surface.id, item);
+ changed = true;
+ }
+ for (const key of keys) {
+ if (item[key].target !== surface[key]) changed = true;
+ item[key].target = surface[key];
+ }
+ }
+ if (changed) this.begin();
+ else if (!this.moving) this.pruneRemoved();
+ return changed;
+ }
+
+ pulse(id: string, kind: 'compress' | 'expand') {
+ if (!this.present.has(id)) return false;
+ const replayBase = {
+ ...(this.pulses.get(id)?.current ?? {
+ value: 0,
+ velocity: 0,
+ target: 0,
+ }),
+ };
+ const replayImpulse = kind === 'compress' ? -1 : 1;
+ const current = { ...replayBase };
+ current.velocity += replayImpulse * pulseVelocity(this.options);
+ if (current.value === 0 && current.velocity === 0) {
+ this.pulses.delete(id);
+ return true;
+ }
+ this.pulses.set(id, {
+ current,
+ start: { ...current },
+ elapsed: 0,
+ replayBase,
+ replayImpulse,
+ });
+ this.elapsed = 0;
+ return true;
+ }
+
+ snapshot(): MotionSnapshot {
+ return {
+ items: new Map([...this.items].map(([id, item]) => [id, copyItem(item)])),
+ starts: new Map([...this.starts].map(([id, item]) => [id, copyItem(item)])),
+ present: new Set(this.present),
+ openness: { ...this.openness },
+ opennessStart: { ...this.opennessStart },
+ pulses: new Map([...this.pulses].map(([id, pulse]) => [id, copyPulse(pulse)])),
+ options: { ...this.options },
+ geometryElapsed: this.geometryElapsed,
+ elapsed: this.elapsed,
+ moving: this.moving,
+ };
+ }
+
+ restore(snapshot: MotionSnapshot) {
+ this.items.clear();
+ for (const [id, item] of snapshot.items) this.items.set(id, copyItem(item));
+ this.starts.clear();
+ for (const [id, item] of snapshot.starts) this.starts.set(id, copyItem(item));
+ this.present = new Set(snapshot.present);
+ Object.assign(this.openness, snapshot.openness);
+ this.opennessStart = { ...snapshot.opennessStart };
+ this.pulses = new Map(
+ [...snapshot.pulses].map(([id, pulse]) => [id, copyPulse(pulse)]),
+ );
+ this.options = { ...snapshot.options };
+ this.geometryElapsed = snapshot.geometryElapsed;
+ this.elapsed = snapshot.elapsed;
+ this.moving = snapshot.moving;
+ }
+
+ seek(closed: Surface[], open: Surface[], progress: number) {
+ const from = new Map(normalizedSurfaces(closed).map(surface => [surface.id, surface]));
+ const to = normalizedSurfaces(open);
+ const position = Number.isFinite(progress) ? clamp(progress) : 0;
+ this.dispose();
+ for (const surface of to) {
+ const start = from.get(surface.id) ?? surface;
+ const item = makeItem(surface);
+ for (const key of keys) {
+ const value = lerp(start[key], surface[key], position);
+ item[key] = { value, target: value, velocity: 0 };
+ }
+ this.items.set(surface.id, item);
+ this.present.add(surface.id);
+ }
+ this.openness.value = position;
+ this.openness.target = position;
+ }
+
+ step(dtSeconds: number, reduced = false) {
+ if (reduced) {
+ for (const item of this.items.values()) for (const key of keys) {
+ item[key].value = item[key].target;
+ item[key].velocity = 0;
+ }
+ this.openness.value = this.openness.target;
+ this.openness.velocity = 0;
+ this.pulses.clear();
+ this.starts.clear();
+ this.moving = false;
+ this.elapsed = 0;
+ this.geometryElapsed = 0;
+ this.pruneRemoved();
+ return;
+ }
+ if (!this.active) return;
+ const delta = Number.isFinite(dtSeconds) ? Math.max(0, dtSeconds) : 0;
+ const duration = this.options.duration / 1000;
+ this.elapsed = advanceElapsed(this.elapsed, delta, duration);
+ if (this.moving) {
+ this.geometryElapsed = advanceElapsed(this.geometryElapsed, delta, duration);
+ for (const item of this.items.values()) for (const key of keys) {
+ const start = this.starts.get(item.id)![key];
+ Object.assign(item[key], timedSpring(start, this.geometryElapsed, duration, this.options.bounce));
+ }
+ Object.assign(this.openness, timedSpring(this.opennessStart, this.geometryElapsed, duration, this.options.bounce));
+ if (this.geometryElapsed === duration) {
+ this.moving = false;
+ this.starts.clear();
+ this.pruneRemoved();
+ }
+ }
+ for (const [id, pulse] of this.pulses) {
+ pulse.elapsed = advanceElapsed(pulse.elapsed, delta, duration);
+ if (pulse.elapsed === duration) this.pulses.delete(id);
+ else pulse.current = timedSpring(pulse.start, pulse.elapsed, duration, this.options.bounce);
+ }
+ }
+
+ private pruneRemoved() {
+ for (const id of this.items.keys()) if (!this.present.has(id)) {
+ this.items.delete(id);
+ this.starts.delete(id);
+ this.pulses.delete(id);
+ }
+ }
+
+ dispose(): void {
+ this.items.clear();
+ this.starts.clear();
+ this.present.clear();
+ this.pulses.clear();
+ Object.assign(this.openness, { value: 0, velocity: 0, target: 0 });
+ this.opennessStart = { ...this.openness };
+ this.geometryElapsed = 0;
+ this.elapsed = 0;
+ this.moving = false;
+ }
+
+ get active() { return this.moving || this.pulses.size > 0; }
+ get elapsedMs() { return this.elapsed * 1000; }
+ get durationMs() { return this.options.duration; }
+ get progress() { return clamp(this.openness.value); }
+
+ get surfaces(): Surface[] {
+ return [...this.items.values()].map(item => {
+ const scale = Math.max(0.1, 1 + (this.pulses.get(item.id)?.current.value ?? 0));
+ return {
+ id: item.id,
+ x: item.x.value,
+ y: item.y.value,
+ w: Math.max(0, item.w.value * scale),
+ h: Math.max(0, item.h.value * scale),
+ r: Math.max(0, item.r.value * scale),
+ };
+ });
+ }
+}
diff --git a/packages/react/src/morph/renderer.ts b/packages/react/src/morph/renderer.ts
new file mode 100644
index 0000000..d74f236
--- /dev/null
+++ b/packages/react/src/morph/renderer.ts
@@ -0,0 +1,350 @@
+import * as THREE from "three";
+import { createOpticalMaterial } from "../engine/sampling.js";
+import { environment, addLights, updateMaterial } from "../engine/material.js";
+import type { GlassSettings } from "../types.js";
+import { boundedPixelRatio } from "../engine/resolution.js";
+import { FIELD_GLSL } from "./field.js";
+import { MAX_SURFACES, type Size, type Surface } from "./model.js";
+
+const UNIT = (1.42 * 1.08 * 2) / 280;
+const sharedVertex = /* glsl */ `
+uniform vec2 uSize;
+varying vec2 vPoint;
+void main() {
+ vPoint = vec2(uv.x * uSize.x, (1.0 - uv.y) * uSize.y);
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+}
+`;
+
+export class MorphRenderer {
+ readonly renderer: THREE.WebGLRenderer;
+ readonly scene = new THREE.Scene();
+ readonly camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 40);
+ readonly optical = createOpticalMaterial();
+ readonly settings: GlassSettings;
+ private baseThickness: number;
+ readonly uniforms = {
+ uShapes: {
+ value: Array.from({ length: MAX_SURFACES }, () => new THREE.Vector4()),
+ },
+ uRadii: { value: Array.from({ length: MAX_SURFACES }, () => 0) },
+ uCount: { value: 0 },
+ uBridge: { value: 22 },
+ uSize: { value: new THREE.Vector2(1, 1) },
+ uDepth: { value: 0.44 },
+ uRim: { value: 0.95 },
+ uLight: { value: new THREE.Vector2(-0.707, 0.707) },
+ uFlat: { value: 0 },
+ uBodyLens: { value: 1 },
+ uShadowOpacity: { value: 0.16 },
+ };
+ private env: THREE.WebGLRenderTarget;
+ private mesh: THREE.Mesh;
+ private shadow: THREE.Mesh;
+ private source?: THREE.CanvasTexture;
+ private disposed = false;
+ private size: Size = { w: 1, h: 1 };
+ private lost = false;
+ private failed = false;
+ private abort = new AbortController();
+ frames = 0;
+ lastSubmissionMs = 0;
+
+ constructor(
+ canvas: HTMLCanvasElement,
+ settings: GlassSettings,
+ onError: (message: string) => void,
+ onRestore: () => void,
+ ) {
+ this.settings = { ...settings };
+ this.baseThickness = settings.thickness;
+ this.renderer = new THREE.WebGLRenderer({
+ canvas,
+ alpha: true,
+ antialias: true,
+ powerPreference: "default",
+ });
+ this.renderer.setClearColor(0, 0);
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ this.renderer.toneMappingExposure = this.settings.exposure;
+ this.renderer.autoClear = false;
+ this.renderer.debug.onShaderError = (gl, program, vertex, fragment) => {
+ this.failed = true;
+ onError(
+ gl.getShaderInfoLog(fragment) ||
+ gl.getShaderInfoLog(vertex) ||
+ gl.getProgramInfoLog(program) ||
+ "Shader compilation failed",
+ );
+ };
+ this.camera.position.z = 12;
+ this.env = environment(this.renderer);
+ this.scene.environment = this.env.texture;
+ addLights(this.scene);
+ const material = this.optical.material;
+ updateMaterial(material, this.settings);
+ material.transparent = true;
+ material.depthWrite = false;
+ material.side = THREE.FrontSide;
+ const original = material.onBeforeCompile;
+ material.onBeforeCompile = (shader, renderer) => {
+ original(shader, renderer);
+ Object.assign(shader.uniforms, this.uniforms);
+ shader.vertexShader =
+ "uniform vec2 uSize; varying vec2 vPoint;\n" + shader.vertexShader;
+ shader.vertexShader = shader.vertexShader.replace(
+ "#include ",
+ "#include \nvPoint = vec2(uv.x * uSize.x, (1.0 - uv.y) * uSize.y);",
+ );
+ shader.fragmentShader =
+ FIELD_GLSL +
+ `
+varying vec2 vPoint;
+uniform float uDepth;
+uniform float uRim;
+uniform vec2 uLight;
+uniform float uFlat;
+uniform float uBodyLens;
+` +
+ shader.fragmentShader;
+ shader.fragmentShader = shader.fragmentShader.replace(
+ "#include ",
+ `
+vec4 surface = field(vPoint);
+float edgeAA = max(0.5, fwidth(surface.x));
+if (surface.x > edgeAA) discard;
+diffuseColor.a *= 1.0 - smoothstep(-edgeAA, edgeAA, surface.x);
+#include
+float epsilon = 0.35;
+vec2 gradient = vec2(
+ field(vPoint + vec2(epsilon, 0.0)).x - field(vPoint - vec2(epsilon, 0.0)).x,
+ field(vPoint + vec2(0.0, epsilon)).x - field(vPoint - vec2(0.0, epsilon)).x
+);
+float gradientLength = length(gradient);
+gradient /= max(gradientLength, 0.00001);
+float localUnit = 3.0672 / max(30.0, surface.y);
+float bevel = max(0.5, min(min(surface.w * 0.45, min(surface.y, surface.z) * 0.18), uDepth * 0.6 / localUnit));
+float q = clamp(1.0 + surface.x / bevel, 0.0, 0.9999);
+float radial = uDepth * 0.32 * q;
+float nz = bevel * localUnit * sqrt(max(0.00001, 1.0 - q * q));
+normal = normalize(vec3(gradient.x * radial, -gradient.y * radial, nz));
+float bodyRadius = max(1.0, min(surface.y, surface.z) * 0.5);
+float bodySlope = 0.24 * clamp(1.0 + surface.x / bodyRadius, 0.0, 1.0) * uBodyLens;
+normal = normalize(vec3(normal.xy + vec2(gradient.x, -gradient.y) * normal.z * bodySlope, normal.z));
+nonPerturbedNormal = normal;
+`,
+ );
+ shader.fragmentShader = shader.fragmentShader.replace(
+ "#include ",
+ `
+#include
+float rim = exp(-abs(surface.x + 0.7) / 1.15);
+float directional = pow(max(0.0, dot(vec2(gradient.x, -gradient.y), uLight)), 2.0);
+totalEmissiveRadiance += vec3(0.94, 0.97, 1.0) * rim * (0.08 + directional * 0.8) * uRim;
+`,
+ );
+ shader.fragmentShader = shader.fragmentShader.replace(
+ "#include ",
+ `
+if (uFlat > 0.5) outgoingLight = vec3(0.68, 0.79, 0.92) + vec3(0.18) * normal.z;
+#include
+`,
+ );
+ };
+ material.customProgramCacheKey = () => "react-glaze-morph-field-v1";
+ this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
+ this.mesh.position.z = 0.32 + this.settings.depth * 0.72;
+ const shadowMaterial = new THREE.ShaderMaterial({
+ uniforms: this.uniforms,
+ vertexShader: sharedVertex,
+ fragmentShader:
+ FIELD_GLSL +
+ `
+varying vec2 vPoint;
+uniform float uShadowOpacity;
+void main() {
+ float d = field(vPoint - vec2(2.5, 8.0)).x;
+ float alpha = exp(-pow(max(0.0, d) / 12.0, 2.0)) * uShadowOpacity;
+ if (alpha < 0.002) discard;
+ gl_FragColor = vec4(0.025, 0.04, 0.06, alpha);
+}
+`,
+ transparent: true,
+ depthWrite: false,
+ toneMapped: false,
+ });
+ this.shadow = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), shadowMaterial);
+ this.shadow.position.z = -0.5;
+ this.shadow.renderOrder = 0;
+ this.mesh.renderOrder = 1;
+ this.scene.add(this.shadow, this.mesh);
+ canvas.addEventListener(
+ "webglcontextlost",
+ (event) => {
+ event.preventDefault();
+ this.lost = true;
+ onError("Graphics context lost; native controls remain available.");
+ },
+ { signal: this.abort.signal },
+ );
+ canvas.addEventListener(
+ "webglcontextrestored",
+ () => {
+ if (this.disposed) return;
+ try {
+ this.lost = false;
+ this.failed = false;
+ this.env.dispose();
+ this.env = environment(this.renderer);
+ this.scene.environment = this.env.texture;
+ if (this.source) this.source.needsUpdate = true;
+ onRestore();
+ } catch (error) {
+ this.failed = true;
+ onError(String(error));
+ }
+ },
+ { signal: this.abort.signal },
+ );
+ }
+
+ resize(size: Size) {
+ if (this.disposed || size.w < 1 || size.h < 1) return;
+ this.size = size;
+ const dpr = boundedPixelRatio(
+ size.w,
+ size.h,
+ Math.min(devicePixelRatio || 1, this.settings.maxDpr),
+ );
+ this.renderer.setDrawingBufferSize(
+ Math.round(size.w * dpr),
+ Math.round(size.h * dpr),
+ 1,
+ );
+ this.uniforms.uSize.value.set(size.w, size.h);
+ this.camera.left = (-size.w * UNIT) / 2;
+ this.camera.right = -this.camera.left;
+ this.camera.top = (size.h * UNIT) / 2;
+ this.camera.bottom = -this.camera.top;
+ this.camera.updateProjectionMatrix();
+ for (const mesh of [this.mesh, this.shadow]) {
+ mesh.geometry.dispose();
+ mesh.geometry = new THREE.PlaneGeometry(size.w * UNIT, size.h * UNIT);
+ }
+ }
+
+ setSource(canvas: HTMLCanvasElement) {
+ if (this.disposed) {
+ canvas.width = canvas.height = 0;
+ return;
+ }
+ const old = this.source;
+ this.source = new THREE.CanvasTexture(canvas);
+ this.source.colorSpace = THREE.SRGBColorSpace;
+ this.source.minFilter = THREE.LinearMipmapLinearFilter;
+ this.source.magFilter = THREE.LinearFilter;
+ this.source.generateMipmaps = true;
+ this.source.needsUpdate = true;
+ old?.dispose();
+ if (old?.image) old.image.width = old.image.height = 0;
+ }
+
+ setLight(x: number, y: number) {
+ if (Math.hypot(x, y) > 0.1)
+ this.uniforms.uLight.value.set(x, -y).normalize();
+ }
+
+ get available() {
+ return !this.disposed && !this.lost && !this.failed;
+ }
+
+ configure(settings: GlassSettings, refraction: number) {
+ Object.assign(this.settings, settings);
+ this.baseThickness = settings.thickness;
+ updateMaterial(this.optical.material, this.settings);
+ this.renderer.toneMappingExposure = settings.exposure;
+ this.uniforms.uDepth.value = settings.depth;
+ this.uniforms.uShadowOpacity.value = settings.shadowOpacity;
+ this.mesh.position.z = 0.32 + settings.depth * 0.72;
+ this.setRefraction(refraction);
+ }
+
+ setRefraction(strength: number) {
+ const normalized = Number.isFinite(strength)
+ ? Math.max(0, Math.min(3, strength))
+ : 1;
+ this.settings.thickness = this.baseThickness * normalized;
+ this.optical.material.thickness = this.settings.thickness;
+ }
+
+ draw(surfaces: Surface[], bridge: number, flat = false) {
+ if (!this.available || !this.source || !this.settings.enabled) return false;
+ const visible = surfaces
+ .filter((s) => s.w > 0.01 && s.h > 0.01)
+ .slice(0, MAX_SURFACES);
+ this.uniforms.uCount.value = visible.length;
+ this.uniforms.uBridge.value = bridge;
+ this.uniforms.uFlat.value = Number(flat);
+ visible.forEach((s, i) => {
+ this.uniforms.uShapes.value[i].set(s.x, s.y, s.w, s.h);
+ this.uniforms.uRadii.value[i] = s.r;
+ });
+ const center = visible[0] ?? { x: this.size.w / 2, y: this.size.h / 2 };
+ this.optical.update(
+ this.source,
+ this.settings,
+ (center.x - this.size.w / 2) * UNIT,
+ (this.size.h / 2 - center.y) * UNIT,
+ );
+ const dpr = this.renderer.domElement.width / this.size.w;
+ const padding = bridge + 38;
+ const left = Math.max(
+ 0,
+ Math.min(...visible.map((s) => s.x - s.w / 2)) - padding,
+ );
+ const right = Math.min(
+ this.size.w,
+ Math.max(...visible.map((s) => s.x + s.w / 2)) + padding,
+ );
+ const top = Math.max(
+ 0,
+ Math.min(...visible.map((s) => s.y - s.h / 2)) - padding,
+ );
+ const bottom = Math.min(
+ this.size.h,
+ Math.max(...visible.map((s) => s.y + s.h / 2)) + padding,
+ );
+ this.renderer.setScissorTest(false);
+ this.renderer.clear();
+ if (!visible.length || right <= left || bottom <= top) return true;
+ this.renderer.setScissor(
+ Math.floor(left * dpr),
+ Math.floor((this.size.h - bottom) * dpr),
+ Math.ceil((right - left) * dpr),
+ Math.ceil((bottom - top) * dpr),
+ );
+ this.renderer.setScissorTest(true);
+ const started = performance.now();
+ this.renderer.render(this.scene, this.camera);
+ this.lastSubmissionMs = performance.now() - started;
+ this.frames++;
+ return this.available;
+ }
+
+ dispose() {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.abort.abort();
+ this.mesh.geometry.dispose();
+ this.optical.material.dispose();
+ this.shadow.geometry.dispose();
+ (this.shadow.material as THREE.Material).dispose();
+ this.env.dispose();
+ this.source?.dispose();
+ if (this.source?.image)
+ this.source.image.width = this.source.image.height = 0;
+ this.renderer.dispose();
+ this.renderer.forceContextLoss();
+ }
+}
diff --git a/packages/react/src/morph/source.ts b/packages/react/src/morph/source.ts
new file mode 100644
index 0000000..98c74be
--- /dev/null
+++ b/packages/react/src/morph/source.ts
@@ -0,0 +1,232 @@
+import { captureBackdrop } from "../engine/capture.js";
+import { withCaptureLock } from "../engine/capture-lock.js";
+import { includesCaptureNode } from "../engine/capture-filter.js";
+import { boundedPixelRatio } from "../engine/resolution.js";
+import { waitForCaptureImages } from "../engine/resources.js";
+import { createQueue } from "../engine/queue.js";
+import type { CaptureRegion } from "../engine/capture-regions.js";
+
+const overlay = "[data-liquid-layer], [data-liquid-overlay]";
+function opaque(element: HTMLElement) {
+ const style = getComputedStyle(element),
+ color = style.backgroundColor;
+ return (
+ Number(style.opacity) === 1 &&
+ color !== "transparent" &&
+ (color.startsWith("rgb(") || /^rgba\([^)]*,\s*1\)$/.test(color))
+ );
+}
+export function groupBackdrop(
+ host: HTMLElement,
+ explicit?: HTMLElement | null,
+): HTMLElement {
+ if (explicit?.isConnected && explicit !== host && !host.contains(explicit))
+ return explicit;
+ const bounds = host.getBoundingClientRect();
+ for (
+ let node = host.parentElement;
+ node && node !== document.body;
+ node = node.parentElement
+ ) {
+ const rect = node.getBoundingClientRect();
+ if (
+ !node.closest(overlay) &&
+ opaque(node) &&
+ rect.left <= bounds.left &&
+ rect.top <= bounds.top &&
+ rect.right >= bounds.right &&
+ rect.bottom >= bounds.bottom
+ )
+ return node;
+ }
+ return document.body;
+}
+
+export class MorphSource {
+ private abort = new AbortController();
+ private mutation: MutationObserver;
+ private resize: ResizeObserver;
+ private motionFrame = 0;
+ private active = true;
+ private disposed = false;
+ private queue;
+
+ constructor(
+ private host: HTMLElement,
+ private options: () => { backdrop?: HTMLElement | null; maxDpr: number },
+ onSource: (canvas: HTMLCanvasElement) => void,
+ onError: (error: unknown) => void,
+ ) {
+ this.queue = createQueue({
+ captureInterval: 1000 / 12,
+ capture: async (begin) => {
+ await document.fonts.ready;
+ this.abort.signal.throwIfAborted();
+ const root = groupBackdrop(this.host, this.options().backdrop);
+ const bounds = this.host.getBoundingClientRect();
+ if (bounds.width <= 0 || bounds.height <= 0) return;
+ const visibleImage = (image: HTMLElement) => {
+ if (!includesCaptureNode(image)) return false;
+ const box = image.getBoundingClientRect();
+ return (
+ box.right > bounds.left &&
+ box.left < bounds.right &&
+ box.bottom > bounds.top &&
+ box.top < bounds.bottom
+ );
+ };
+ await waitForCaptureImages(root, visibleImage, this.abort.signal);
+ return withCaptureLock(this.abort.signal, async () => {
+ begin();
+ if (root !== groupBackdrop(this.host, this.options().backdrop)) {
+ this.refresh();
+ return;
+ }
+ const box = this.host.getBoundingClientRect(),
+ origin = root.getBoundingClientRect();
+ if (box.width <= 0 || box.height <= 0) return;
+ const ratio = boundedPixelRatio(
+ box.width,
+ box.height,
+ Math.min(devicePixelRatio || 1, this.options().maxDpr),
+ );
+ const region: CaptureRegion = {
+ x: 0,
+ y: 0,
+ width: Math.max(1, Math.floor(box.width * ratio)),
+ height: Math.max(1, Math.floor(box.height * ratio)),
+ css: {
+ left: box.left - origin.left,
+ top: box.top - origin.top,
+ right: box.right - origin.left,
+ bottom: box.bottom - origin.top,
+ },
+ prune: () => false,
+ };
+ return captureBackdrop(
+ root,
+ {
+ pixelRatio: ratio,
+ filter: includesCaptureNode,
+ fontEmbedCSS: "",
+ },
+ region,
+ );
+ });
+ },
+ commit: (canvas) => {
+ if (canvas) onSource(canvas);
+ },
+ render: () => {},
+ release: (canvas) => {
+ if (canvas) canvas.width = canvas.height = 0;
+ },
+ onError,
+ });
+ const refresh = () => this.refresh();
+ const changed = (event: Event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ if (target && !target.closest(overlay)) this.refresh();
+ };
+ const opts = { signal: this.abort.signal };
+ this.mutation = new MutationObserver((records) => {
+ if (
+ records.some((record) => {
+ const node =
+ record.target instanceof Element
+ ? record.target
+ : record.target.parentElement;
+ return node && !node.closest(overlay);
+ })
+ ) {
+ this.refresh();
+ this.trackMotion();
+ }
+ });
+ this.mutation.observe(document.documentElement, {
+ subtree: true,
+ attributes: true,
+ childList: true,
+ characterData: true,
+ });
+ this.resize = new ResizeObserver(refresh);
+ this.resize.observe(host);
+ this.resize.observe(document.documentElement);
+ document.addEventListener("scroll", refresh, {
+ ...opts,
+ capture: true,
+ passive: true,
+ });
+ for (const name of [
+ "load",
+ "error",
+ "input",
+ "change",
+ "focusin",
+ "focusout",
+ "pointerover",
+ "pointerout",
+ ])
+ document.addEventListener(name, changed, { ...opts, capture: true });
+ window.addEventListener("resize", refresh, opts);
+ document.fonts.addEventListener("loadingdone", refresh, opts);
+ for (const name of [
+ "animationstart",
+ "animationend",
+ "animationcancel",
+ "transitionrun",
+ "transitionend",
+ "transitioncancel",
+ ])
+ document.addEventListener(
+ name,
+ () => {
+ this.refresh();
+ this.trackMotion();
+ },
+ { ...opts, capture: true },
+ );
+ this.refresh();
+ this.trackMotion();
+ }
+ private trackMotion = () => {
+ if (this.disposed || !this.active || this.motionFrame) return;
+ const root = groupBackdrop(this.host, this.options().backdrop);
+ const moving = document.getAnimations().some((animation) => {
+ const target = (animation.effect as KeyframeEffect | null)?.target;
+ return (
+ animation.playState === "running" &&
+ target instanceof Element &&
+ (target.contains(this.host) ||
+ (!target.closest(overlay) &&
+ (root.contains(target) || target.contains(root))))
+ );
+ });
+ if (!moving) return;
+ this.queue.sourceChanged(true);
+ this.motionFrame = requestAnimationFrame(() => {
+ this.motionFrame = 0;
+ this.trackMotion();
+ });
+ };
+ refresh(continuous = false) {
+ if (!this.disposed) this.queue.sourceChanged(continuous);
+ }
+ setActive(active: boolean) {
+ this.active = active;
+ this.queue.setActive(active);
+ if (!active) {
+ cancelAnimationFrame(this.motionFrame);
+ this.motionFrame = 0;
+ } else this.trackMotion();
+ }
+ dispose() {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.abort.abort();
+ this.mutation.disconnect();
+ this.resize.disconnect();
+ cancelAnimationFrame(this.motionFrame);
+ this.queue.dispose();
+ }
+}
diff --git a/packages/react/src/morph/split-layout.ts b/packages/react/src/morph/split-layout.ts
new file mode 100644
index 0000000..8e1c6a1
--- /dev/null
+++ b/packages/react/src/morph/split-layout.ts
@@ -0,0 +1,143 @@
+import { clamp, type Direction, type Size, type SplitArrangement, type Surface } from './model.js';
+
+type Item = Pick;
+interface SplitResult { surfaces: Surface[]; direction: Direction; fits: boolean; wrapped: boolean; distance: number }
+const GAP = 12;
+const TRIGGER_GAP = 8;
+const MARGIN = 20;
+const FAN_SWEEP = 116 * Math.PI / 180;
+
+export function arrangementDirections(arrangement: SplitArrangement): Direction[] {
+ return arrangement === 'row' ? ['left', 'right'] :
+ arrangement === 'column' ? ['top', 'bottom'] : ['left', 'top', 'right', 'bottom'];
+}
+export function arrangementDirection(arrangement: SplitArrangement, direction: Direction): Direction {
+ return arrangementDirections(arrangement).includes(direction) ? direction :
+ arrangement === 'row' ? 'right' : 'top';
+}
+
+function centered(surfaces: Surface[], view: Size) {
+ const left = Math.min(...surfaces.map(s => s.x - s.w / 2));
+ const right = Math.max(...surfaces.map(s => s.x + s.w / 2));
+ const top = Math.min(...surfaces.map(s => s.y - s.h / 2));
+ const bottom = Math.max(...surfaces.map(s => s.y + s.h / 2));
+ const dx = (view.w - left - right) / 2, dy = (view.h - top - bottom) / 2;
+ return {
+ surfaces: surfaces.map(s => ({ ...s, x: s.x + dx, y: s.y + dy })),
+ fits: right - left <= view.w - MARGIN * 2 && bottom - top <= view.h - MARGIN * 2,
+ };
+}
+
+function linear(items: Item[], direction: Direction) {
+ const row = direction === 'left' || direction === 'right';
+ const sign = direction === 'left' || direction === 'top' ? -1 : 1;
+ let offset = 0;
+ return items.map((item, i): Surface => {
+ const along = row ? item.w : item.h;
+ if (i) offset += (row ? items[i - 1].w : items[i - 1].h) / 2 + GAP + along / 2;
+ return { ...item, x: row ? sign * offset : 0, y: row ? 0 : sign * offset, r: item.h / 2 };
+ });
+}
+
+function wrapped(items: Item[], arrangement: SplitArrangement, direction: Direction, view: Size) {
+ const columnFirst = arrangement === 'column';
+ const available = (columnFirst ? view.h : view.w) - MARGIN * 2;
+ const lines: Item[][] = [[]];
+ let used = 0;
+ for (const item of items) {
+ const length = columnFirst ? item.h : item.w;
+ if (lines.at(-1)!.length && used + GAP + length > available) {
+ lines.push([]); used = 0;
+ }
+ const line = lines.at(-1)!;
+ used += (line.length ? GAP : 0) + length; line.push(item);
+ }
+ const surfaces: Surface[] = [];
+ let cross = 0;
+ for (const line of lines) {
+ const thickness = Math.max(...line.map(s => columnFirst ? s.w : s.h));
+ let along = 0;
+ for (const item of line) {
+ const length = columnFirst ? item.h : item.w;
+ surfaces.push({ ...item,
+ x: columnFirst ? cross + thickness / 2 : along + length / 2,
+ y: columnFirst ? along + length / 2 : cross + thickness / 2,
+ r: item.h / 2 });
+ along += length + GAP;
+ }
+ cross += thickness + GAP;
+ }
+ return surfaces.map(s => ({ ...s, x: direction === 'left' ? -s.x : s.x,
+ y: direction === 'top' ? -s.y : s.y }));
+}
+
+function fan(items: Item[], direction: Direction, sweep: number): Surface[] {
+ const count = items.length - 1;
+ const angle = ({ top: -Math.PI / 2, right: 0, bottom: Math.PI / 2, left: Math.PI })[direction];
+ const unit = items.map((_, i) => {
+ if (!i) return { x: 0, y: 0 };
+ const theta = angle + (count === 1 ? 0 : ((i - 1) / (count - 1) - 0.5) * sweep);
+ return { x: Math.cos(theta), y: Math.sin(theta) };
+ });
+ let radius = 0;
+ // Find the radius that separates measured hit boxes, including the trigger.
+ // A pair clears when either its horizontal or vertical gap is sufficient.
+ for (let i = 0; i < items.length; i++) for (let j = i + 1; j < items.length; j++) {
+ const dx = Math.abs(unit[i].x - unit[j].x), dy = Math.abs(unit[i].y - unit[j].y);
+ const neededX = ((items[i].w + items[j].w) / 2 + GAP) / Math.max(dx, 1e-9);
+ const neededY = ((items[i].h + items[j].h) / 2 + GAP) / Math.max(dy, 1e-9);
+ radius = Math.max(radius, Math.min(neededX, neededY));
+ }
+ return items.map((s, i) => ({ ...s, x: unit[i].x * radius, y: unit[i].y * radius, r: s.h / 2 }));
+}
+
+function fitDistance(view: Size, surfaces: Surface[], direction: Direction, requested: number) {
+ const compact = centered(surfaces, view);
+ if (!compact.fits) return undefined;
+ const row = direction === 'left' || direction === 'right';
+ const sign = direction === 'left' || direction === 'top' ? -1 : 1;
+ const along = row ? 'x' : 'y', across = row ? 'y' : 'x';
+ const length = row ? 'w' : 'h', breadth = row ? 'h' : 'w';
+ const trigger = surfaces[0];
+ let minimum = -160;
+ for (const action of surfaces.slice(1)) {
+ if (Math.abs(action[across] - trigger[across]) >= (action[breadth] + trigger[breadth]) / 2 + TRIGGER_GAP) continue;
+ minimum = Math.max(minimum, (action[length] + trigger[length]) / 2 + TRIGGER_GAP - sign * (action[along] - trigger[along]));
+ }
+ const offset = Math.max(minimum, requested);
+ const generate = (distance: number) => surfaces.map((s, i) => i ? { ...s, [along]: s[along] + sign * distance } : s);
+ const full = centered(generate(offset), view);
+ if (full.fits) return { ...full, distance: offset };
+ let low = 0, high = 1;
+ for (let i = 0; i < 20; i++) {
+ const middle = (low + high) / 2;
+ if (centered(generate(middle * offset), view).fits) low = middle; else high = middle;
+ }
+ const distance = Math.trunc(low * offset * 100) / 100;
+ return { ...centered(generate(distance), view), distance };
+}
+
+export function makeSplitLayout(view: Size, button: number, actions: Item[],
+ arrangement: SplitArrangement, requestedDirection: Direction, distance = 0): SplitResult {
+ if (![view.w, view.h, button, ...actions.flatMap(action => [action.w, action.h])]
+ .every(value => Number.isFinite(value) && value >= 0)) {
+ throw new RangeError('Split layout requires finite, non-negative sizes.');
+ }
+ distance = actions.length && Number.isFinite(distance) ? clamp(distance, -160, 120) : 0;
+ const direction = arrangementDirection(arrangement, requestedDirection);
+ const items: Item[] = [{ id: 'trigger', w: button, h: button }, ...actions];
+ if (arrangement === 'fan') {
+ const compactSweep = Math.min(FAN_SWEEP, Math.max(0, actions.length - 1) * FAN_SWEEP / 2);
+ const directions = [direction, ...(['top', 'right', 'bottom', 'left'] as const).filter(d => d !== direction)];
+ const sweeps = [...new Set([compactSweep, Math.max(compactSweep, Math.PI * 0.8), Math.PI])];
+ for (const candidate of directions) for (const sweep of sweeps) {
+ const result = fitDistance(view, fan(items, candidate, sweep), candidate, distance);
+ if (result) return { ...result, direction: candidate, wrapped: false };
+ }
+ } else {
+ const result = fitDistance(view, linear(items, direction), direction, distance);
+ if (result) return { ...result, direction, wrapped: false };
+ }
+ const result = centered(wrapped(items, arrangement, direction, view), view);
+ return { ...result, direction, wrapped: true, distance: 0 };
+}
diff --git a/packages/react/src/morph/styles.ts b/packages/react/src/morph/styles.ts
new file mode 100644
index 0000000..7c0164f
--- /dev/null
+++ b/packages/react/src/morph/styles.ts
@@ -0,0 +1,14 @@
+export const morphStyles = `
+[data-glaze-group]{position:relative;isolation:isolate;box-sizing:border-box}
+[data-glaze-canvas]{position:absolute;inset:0;display:block;visibility:hidden;pointer-events:none;z-index:0;max-width:none}
+[data-glaze-canvas-layer]{position:absolute;inset:0;pointer-events:none;z-index:0}
+[data-glaze-ready="true"]>[data-glaze-canvas-layer]>[data-glaze-canvas]{visibility:inherit}
+[data-glaze-surface]{position:absolute;left:0;top:0;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;overflow:hidden;z-index:1;color:inherit;border:0;padding:0;transform-origin:center;background:color-mix(in srgb,Canvas 65%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);box-shadow:0 4px 16px #0002}
+[data-glaze-ready="true"] [data-glaze-surface]{background:transparent;backdrop-filter:none;-webkit-backdrop-filter:none;box-shadow:none}
+[data-glaze-content]{display:flex;align-items:center;justify-content:center;flex-shrink:0;transform-origin:center}
+[data-glaze-surface][disabled] [data-glaze-content]{opacity:.4}
+button[data-glaze-surface]{cursor:pointer;font:inherit}
+button[data-glaze-surface]:disabled{cursor:default}
+[data-glaze-surface]:focus-visible{outline:2px solid currentColor;outline-offset:3px}
+@media(forced-colors:active){[data-glaze-canvas]{display:none!important}[data-glaze-surface]{background:Canvas!important;color:CanvasText!important;border:1px solid ButtonText}}
+`;
diff --git a/packages/react/tests/morph-api.test.mjs b/packages/react/tests/morph-api.test.mjs
new file mode 100644
index 0000000..2bcc91c
--- /dev/null
+++ b/packages/react/tests/morph-api.test.mjs
@@ -0,0 +1,112 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { createElement as h } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { LiquidGlassGroup, LiquidGlassSurface } from "../dist/index.js";
+import { drawableSurfaces } from "../dist/morph/config.js";
+
+const surface = { id: "save", x: 50, y: 50, width: 60, height: 60 };
+const render = (
+ props = {},
+ child = h(
+ LiquidGlassSurface,
+ { surfaceId: "save", as: "button", "aria-label": "Save" },
+ "Save",
+ ),
+) =>
+ renderToStaticMarkup(
+ h(
+ LiquidGlassGroup,
+ { width: 100, height: 100, surfaces: [surface], ...props },
+ child,
+ ),
+ );
+
+test("group and native surface render on the server with usable fallback", () => {
+ assert.equal(typeof document, "undefined");
+ const html = render();
+ assert.match(html, / {
+ const html = render(
+ { surfaces: [{ ...surface, width: 0, height: 0 }] },
+ h(
+ LiquidGlassSurface,
+ { surfaceId: "save", as: "a", href: "/saved" },
+ "Saved",
+ ),
+ );
+ assert.match(html, /href="\/saved"/);
+ assert.match(html, /inert=""/);
+});
+test("invalid public geometry fails before producing malformed CSS", () => {
+ assert.throws(() => render({ width: NaN }), /finite CSS pixel size/);
+ assert.throws(() => render({ surfaces: [surface, surface] }), /unique/);
+ assert.throws(
+ () => render({ surfaces: [{ ...surface, x: Infinity }] }),
+ /finite geometry/,
+ );
+ assert.throws(
+ () => render({ surfaces: [{ ...surface, x: Number.MAX_VALUE }] }),
+ /finite geometry/,
+ );
+ assert.throws(
+ () =>
+ render({
+ surfaces: Array.from({ length: 9 }, (_, i) => ({
+ ...surface,
+ id: String(i),
+ })),
+ }),
+ /at most 8/,
+ );
+});
+test("replaced surfaces cannot consume the new controls' rendering slots", () => {
+ const old = Array.from({ length: 8 }, (_, i) => ({
+ id: `old-${i}`,
+ x: 20,
+ y: 20,
+ w: 30,
+ h: 30,
+ r: 15,
+ }));
+ const current = old.map((s, i) => ({ ...s, id: `new-${i}` }));
+ assert.deepEqual(
+ drawableSurfaces([...old, ...current], current).map((s) => s.id),
+ current.map((s) => s.id),
+ );
+ assert.deepEqual(drawableSurfaces([], []), []);
+});
+test("group fallback styles deduplicate without changing existing wrapper CSS", () => {
+ const html = renderToStaticMarkup(
+ h(
+ "main",
+ null,
+ h(
+ LiquidGlassGroup,
+ { width: 100, height: 100, surfaces: [surface] },
+ h(LiquidGlassSurface, { surfaceId: "save" }, "One"),
+ ),
+ h(
+ LiquidGlassGroup,
+ { width: 100, height: 100, surfaces: [surface] },
+ h(LiquidGlassSurface, { surfaceId: "save" }, "Two"),
+ ),
+ ),
+ );
+ assert.equal((html.match(/