Browse Source

feat: add Gaussian Splatting support (#259)

* feat: add @json-render/gsplat package

Standalone Gaussian Splatting renderer using Hugging Face's gsplat.js.
Provides GaussianSplat and GaussianSplatViewer components with progress
indicator, orbit controls, and Zod-based catalog definitions — no
Three.js dependency required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add GaussianSplat component to react-three-fiber

Adds GaussianSplat to the R3F renderer using drei's Splat loader,
bringing the component count to 20. Splats are composable with all
existing R3F components (lights, controls, post-processing, etc.).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add standalone gsplat example

Demo app showcasing @json-render/gsplat with 5 scenes (bonsai, garden,
bicycle, kitchen, stump) loaded from Hugging Face datasets. Includes
scene selector, live JSON spec viewer, and progress indicator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add react-three-fiber gsplat example

Demo app showcasing GaussianSplat in R3F with 5 scenes: splat showroom,
splat with primitives, multi-splat, post-processing effects (bloom +
vignette), and animated floating splat with sparkles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add gsplat API reference and update navigation

Adds API documentation for @json-render/gsplat, updates the R3F docs
to reflect 20 components, and registers both example apps in the
docs navigation, examples list, and page titles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add changeset and update lockfile for gsplat

Adds @json-render/gsplat to the fixed version group and creates a
changeset for the minor release of gsplat and react-three-fiber.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: wire up GaussianSplatViewer props to gsplat.js API

The catalog declared controls, autoRotate, autoRotateSpeed,
cameraPosition, cameraTarget, and fov props but the component
silently ignored them. Now:
- cameraPosition sets camera.position via SPLAT.Vector3
- cameraTarget calls controls.setCameraTarget()
- fov converts to focal length via camera.data.fx/fy
- controls=false skips OrbitControls creation
- autoRotate rotates the camera around Y in the render loop
- Updated gsplat.d.ts with full Camera/OrbitControls type surface

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(gsplat): address review issues — transforms, progress bar, themeable colors

- Apply per-splat position/rotation/scale after LoadAsync (splats were
  rendering at origin regardless of config)
- Add eulerToQuaternion helper to convert Vec3 degrees to Quaternion
- Expose Splat class and correct LoadAsync return type (Promise<Splat>)
  in gsplat.d.ts so transforms can be applied imperatively
- Forward visible prop through GaussianSplatHandle and SplatEntry
- Fix progress bar regression: use Math.max so bar never jumps backwards
- Make ProgressIndicator colors themeable via progressBarColor,
  progressTrackColor, progressTextColor, progressBackgroundColor props
  on GaussianSplatViewer
- Remove quality/alphaHash/toneMapped from standalone gsplat catalog —
  these gsplat.js renderer has no equivalent API for them (they remain
  in @json-render/react-three-fiber where drei's Splat supports them)
- Fix pre-existing TS2769/TS2322 Vec3 type errors in GaussianSplat.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(gsplat): pass required progress color props in example

The new themeable progress indicator props are nullable (required keys
that accept null) in the catalog schema. The example was missing them,
causing TS2739 in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(gsplat): drop standalone @json-render/gsplat package

Per discussion in #259, the standalone gsplat viewer is better positioned
as an experimental example than as a published package — there's no real
spec tree to compose, and the R3F GaussianSplat component already covers
the productized use case.

- Remove packages/gsplat (was @json-render/gsplat)
- Inline the viewer component into examples/gsplat as an experimental demo
- Drop @json-render/{core,react,gsplat} deps from the example
- Remove gsplat from changeset config and feat-gsplat.md
- Remove docs/api/gsplat page and its navigation entries
- Update README to drop the standalone install/snippet

* fix(gsplat): address review issues, add ExtrudedText component, improve a11y

- Fix GridHelper prop names (color1/color2 → color/secondaryColor) in all R3F scene files
- Fix Text3D prop mismatch in splat-with-primitives (size → fontSize, remove unsupported bevel/font props)
- Fix autoRotate to orbit around camera target instead of world origin
- Add ExtrudedText component to @json-render/react-three-fiber (geometry-based 3D text with depth, bevel, and custom font support)
- Add multi-offset scene to gsplat example to demonstrate off-center orbit
- Redesign splat-with-primitives scene (triangle logo + extruded label, remove floor plane)
- Improve gsplat example accessibility (ARIA labels, keyboard nav, semantic HTML, focus management)
- Debounce resize handler and use consistent 100dvh in gsplat viewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Lucian Fialho <lucianfialhobp@hotmail.com>
William Manzoli 3 months ago
parent
commit
c604e5983c
39 changed files with 2832 additions and 5 deletions
  1. 10 0
      .changeset/feat-gsplat.md
  2. 6 2
      README.md
  3. 6 1
      apps/web/app/(main)/docs/api/react-three-fiber/page.mdx
  4. 16 0
      apps/web/lib/examples.ts
  5. 421 0
      examples/gsplat/app/GaussianSplatViewer.tsx
  6. 79 0
      examples/gsplat/app/gsplat.d.ts
  7. 21 0
      examples/gsplat/app/layout.tsx
  8. 460 0
      examples/gsplat/app/page.tsx
  9. 24 0
      examples/gsplat/app/scenes/_helpers.ts
  10. 41 0
      examples/gsplat/app/scenes/bicycle.ts
  11. 41 0
      examples/gsplat/app/scenes/bonsai.ts
  12. 41 0
      examples/gsplat/app/scenes/garden.ts
  13. 17 0
      examples/gsplat/app/scenes/index.ts
  14. 41 0
      examples/gsplat/app/scenes/kitchen.ts
  15. 47 0
      examples/gsplat/app/scenes/multi-offset.ts
  16. 41 0
      examples/gsplat/app/scenes/stump.ts
  17. 11 0
      examples/gsplat/eslint.config.js
  18. 6 0
      examples/gsplat/next-env.d.ts
  19. 5 0
      examples/gsplat/next.config.ts
  20. 29 0
      examples/gsplat/package.json
  21. 13 0
      examples/gsplat/tsconfig.json
  22. 21 0
      examples/react-three-fiber-gsplat/app/layout.tsx
  23. 408 0
      examples/react-three-fiber-gsplat/app/page.tsx
  24. 3 0
      examples/react-three-fiber-gsplat/app/scenes/_helpers.ts
  25. 166 0
      examples/react-three-fiber-gsplat/app/scenes/floating-splat.ts
  26. 15 0
      examples/react-three-fiber-gsplat/app/scenes/index.ts
  27. 146 0
      examples/react-three-fiber-gsplat/app/scenes/multi-splat.ts
  28. 134 0
      examples/react-three-fiber-gsplat/app/scenes/splat-postfx.ts
  29. 126 0
      examples/react-three-fiber-gsplat/app/scenes/splat-showroom.ts
  30. 157 0
      examples/react-three-fiber-gsplat/app/scenes/splat-with-primitives.ts
  31. 12 0
      examples/react-three-fiber-gsplat/eslint.config.js
  32. 6 0
      examples/react-three-fiber-gsplat/next-env.d.ts
  33. 12 0
      examples/react-three-fiber-gsplat/next.config.ts
  34. 36 0
      examples/react-three-fiber-gsplat/package.json
  35. 13 0
      examples/react-three-fiber-gsplat/tsconfig.json
  36. 1 1
      packages/react-three-fiber/README.md
  37. 47 0
      packages/react-three-fiber/src/catalog.ts
  38. 58 0
      packages/react-three-fiber/src/components.tsx
  39. 95 1
      pnpm-lock.yaml

+ 10 - 0
.changeset/feat-gsplat.md

@@ -0,0 +1,10 @@
+---
+"@json-render/react-three-fiber": minor
+---
+
+feat: add Gaussian Splatting support to react-three-fiber
+
+### New:
+- `GaussianSplat` component added to `@json-render/react-three-fiber` using drei's Splat loader
+- New `examples/react-three-fiber-gsplat` app showing R3F composition with splats
+- New `examples/gsplat` experimental demo: a standalone gsplat.js viewer (no Three.js, not published)

+ 6 - 2
README.md

@@ -27,7 +27,7 @@ npm install @json-render/core @json-render/solid
 npm install @json-render/core @json-render/ink ink react
 # or for full Next.js apps (routes, layouts, SSR, metadata)
 npm install @json-render/core @json-render/react @json-render/next
-# or for 3D scenes
+# or for 3D scenes (and gaussian splatting via the GaussianSplat component)
 npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
 ```
 
@@ -128,7 +128,7 @@ function Dashboard({ spec }) {
 | `@json-render/solid`        | SolidJS renderer with fine-grained reactive contexts                   |
 | `@json-render/shadcn`       | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS)            |
 | `@json-render/shadcn-svelte`| 36 pre-built shadcn-svelte components (Svelte 5 + Tailwind CSS)        |
-| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components)  |
+| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (20 built-in components, including GaussianSplat)  |
 | `@json-render/react-native` | React Native renderer with standard mobile components                  |
 | `@json-render/next`         | Next.js renderer — JSON becomes full apps with routes, layouts, SSR    |
 | `@json-render/remotion`     | Remotion video renderer, timeline schema                               |
@@ -462,6 +462,7 @@ const catalog = defineCatalog(schema, {
     Sphere: threeComponentDefinitions.Sphere,
     AmbientLight: threeComponentDefinitions.AmbientLight,
     DirectionalLight: threeComponentDefinitions.DirectionalLight,
+    GaussianSplat: threeComponentDefinitions.GaussianSplat,
     OrbitControls: threeComponentDefinitions.OrbitControls,
   },
   actions: {},
@@ -473,6 +474,7 @@ const { registry } = defineRegistry(catalog, {
     Sphere: threeComponents.Sphere,
     AmbientLight: threeComponents.AmbientLight,
     DirectionalLight: threeComponents.DirectionalLight,
+    GaussianSplat: threeComponents.GaussianSplat,
     OrbitControls: threeComponents.OrbitControls,
   },
 });
@@ -730,6 +732,8 @@ pnpm dev
 - Vue Example: run `pnpm dev` in `examples/vue`
 - Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers`
 - React Native example: run `npx expo start` in `examples/react-native`
+- Gaussian Splatting (R3F): run `pnpm dev` in `examples/react-three-fiber-gsplat`
+- Gaussian Splatting (experimental standalone gsplat.js demo): run `pnpm dev` in `examples/gsplat`
 
 ## How It Works
 

+ 6 - 1
apps/web/app/(main)/docs/api/react-three-fiber/page.mdx

@@ -3,7 +3,7 @@ export const metadata = pageMetadata("docs/api/react-three-fiber")
 
 # @json-render/react-three-fiber
 
-React Three Fiber renderer for json-render. 19 built-in 3D components for meshes, lights, models, environments, text, cameras, and controls.
+React Three Fiber renderer for json-render. 20 built-in 3D components for meshes, lights, models, gaussian splats, environments, text, cameras, and controls.
 
 ## Installation
 
@@ -283,6 +283,11 @@ All primitives share: <code>position</code>, <code>rotation</code>, <code>scale<
 <td>Camera controls</td>
 <td><code>enableDamping</code>, <code>enableZoom</code>, <code>autoRotate</code></td>
 </tr>
+<tr>
+<td><code>GaussianSplat</code></td>
+<td>Gaussian splat (.splat/.ply) loader</td>
+<td><code>src</code>, <code>position</code>, <code>rotation</code>, <code>scale</code>, <code>alphaHash</code>, <code>toneMapped</code></td>
+</tr>
 </tbody>
 </table>
 

+ 16 - 0
apps/web/lib/examples.ts

@@ -108,6 +108,22 @@ export const examples: Example[] = [
     githubPath: "examples/react-three-fiber",
     demoUrl: "https://react-three-fiber-demo.json-render.dev",
   },
+  {
+    slug: "gsplat",
+    title: "Gaussian Splatting (experimental)",
+    description:
+      "Experimental standalone gaussian splat viewer using gsplat.js — no Three.js dependency.",
+    tags: ["React", "3D", "Next.js"],
+    githubPath: "examples/gsplat",
+  },
+  {
+    slug: "react-three-fiber-gsplat",
+    title: "R3F + Gaussian Splatting",
+    description:
+      "Gaussian splats composed with 3D primitives and lights in React Three Fiber scenes.",
+    tags: ["React", "3D", "Next.js"],
+    githubPath: "examples/react-three-fiber-gsplat",
+  },
   {
     slug: "react-native",
     title: "React Native",

+ 421 - 0
examples/gsplat/app/GaussianSplatViewer.tsx

@@ -0,0 +1,421 @@
+"use client";
+
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import * as SPLAT from "gsplat";
+
+type Vec3 = [number, number, number];
+
+interface SplatEntry {
+  src: string;
+  position?: Vec3;
+  rotation?: Vec3;
+  scale?: Vec3;
+  visible?: boolean;
+}
+
+interface ViewerProps {
+  width?: string;
+  height?: string;
+  backgroundColor?: string;
+  controls?: boolean;
+  autoRotate?: boolean;
+  autoRotateSpeed?: number;
+  cameraPosition?: Vec3 | null;
+  cameraTarget?: Vec3 | null;
+  fov?: number | null;
+  progressBarColor?: string;
+  progressTrackColor?: string;
+  progressTextColor?: string;
+  progressBackgroundColor?: string;
+  /** Custom loading indicator — overrides the default progress bar */
+  loadingIndicator?: ReactNode;
+  /** Splat file URLs to load */
+  splats?: SplatEntry[];
+  children?: ReactNode;
+}
+
+interface ProgressIndicatorProps {
+  progress: number;
+  backgroundColor?: string;
+  textColor?: string;
+  barColor?: string;
+  trackColor?: string;
+}
+
+function ProgressIndicator({
+  progress,
+  backgroundColor = "#0a0a0a",
+  textColor = "#666",
+  barColor = "#fff",
+  trackColor = "#1e1e1e",
+}: ProgressIndicatorProps) {
+  const pct = Math.round(progress * 100);
+  return (
+    <div
+      style={{
+        position: "absolute",
+        inset: 0,
+        display: "flex",
+        flexDirection: "column",
+        alignItems: "center",
+        justifyContent: "center",
+        background: backgroundColor,
+        gap: 12,
+      }}
+    >
+      <span
+        aria-live="polite"
+        style={{
+          color: textColor,
+          fontFamily: "ui-monospace, monospace",
+          fontSize: 13,
+          letterSpacing: "0.04em",
+        }}
+      >
+        Loading splat... {pct}%
+      </span>
+      <div
+        role="progressbar"
+        aria-valuenow={pct}
+        aria-valuemin={0}
+        aria-valuemax={100}
+        aria-label="Loading progress"
+        style={{
+          width: 200,
+          height: 2,
+          background: trackColor,
+          borderRadius: 1,
+          overflow: "hidden",
+        }}
+      >
+        <div
+          style={{
+            width: `${pct}%`,
+            height: "100%",
+            background: barColor,
+            borderRadius: 1,
+            transition: "width 150ms ease-out",
+          }}
+        />
+      </div>
+    </div>
+  );
+}
+
+/** Convert a vertical FOV (degrees) to a focal length given a canvas height. */
+function fovToFocalLength(fovDeg: number, height: number): number {
+  return height / (2 * Math.tan((fovDeg * Math.PI) / 360));
+}
+
+/** Convert euler angles (degrees, XYZ order) to a Quaternion. */
+function eulerToQuaternion(euler: Vec3): SPLAT.Quaternion {
+  const [ex, ey, ez] = euler.map((d) => (d * Math.PI) / 180) as [
+    number,
+    number,
+    number,
+  ];
+  const cx = Math.cos(ex / 2),
+    sx = Math.sin(ex / 2);
+  const cy = Math.cos(ey / 2),
+    sy = Math.sin(ey / 2);
+  const cz = Math.cos(ez / 2),
+    sz = Math.sin(ez / 2);
+  return new SPLAT.Quaternion(
+    sx * cy * cz + cx * sy * sz,
+    cx * sy * cz - sx * cy * sz,
+    cx * cy * sz + sx * sy * cz,
+    cx * cy * cz - sx * sy * sz,
+  );
+}
+
+/**
+ * Container that manages a WebGL canvas and loads gaussian splats
+ * using Hugging Face's gsplat.js — a standalone WebGL renderer (no Three.js).
+ *
+ * This is an experimental demo component, kept inline in the example app.
+ */
+export function GaussianSplatViewer({
+  width = "100%",
+  height = "100%",
+  backgroundColor = "#000000",
+  controls: enableControls = true,
+  autoRotate = false,
+  autoRotateSpeed = 1,
+  cameraPosition = null,
+  cameraTarget = null,
+  fov = null,
+  progressBarColor,
+  progressTrackColor,
+  progressTextColor,
+  progressBackgroundColor,
+  children,
+  loadingIndicator,
+  splats,
+}: ViewerProps) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const cleanupRef = useRef<(() => void) | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [progress, setProgress] = useState(0);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (!containerRef.current) return;
+
+    const container = containerRef.current;
+    let cancelled = false;
+
+    async function init() {
+      try {
+        // Clean up previous viewer
+        if (cleanupRef.current) {
+          cleanupRef.current();
+          cleanupRef.current = null;
+        }
+
+        const scene = new SPLAT.Scene();
+        const camera = new SPLAT.Camera();
+        const renderer = new SPLAT.WebGLRenderer();
+
+        // Apply camera position
+        if (cameraPosition) {
+          camera.position = new SPLAT.Vector3(
+            cameraPosition[0],
+            cameraPosition[1],
+            cameraPosition[2],
+          );
+        }
+
+        // Apply FOV by converting to focal length
+        const rect = container.getBoundingClientRect();
+        if (fov) {
+          const fl = fovToFocalLength(fov, rect.height);
+          camera.data.fx = fl;
+          camera.data.fy = fl;
+        }
+
+        // Size the canvas to the container
+        renderer.setSize(rect.width, rect.height);
+
+        // Style and append the canvas — absolute positioning prevents overflow
+        renderer.canvas.style.position = "absolute";
+        renderer.canvas.style.top = "0";
+        renderer.canvas.style.left = "0";
+        renderer.canvas.style.width = "100%";
+        renderer.canvas.style.height = "100%";
+        renderer.canvas.style.display = "block";
+        container.appendChild(renderer.canvas);
+
+        // Only create orbit controls if enabled
+        let controls: SPLAT.OrbitControls | null = null;
+        if (enableControls) {
+          controls = new SPLAT.OrbitControls(camera, renderer.canvas);
+
+          // Apply camera target (look-at)
+          if (cameraTarget) {
+            controls.setCameraTarget(
+              new SPLAT.Vector3(
+                cameraTarget[0],
+                cameraTarget[1],
+                cameraTarget[2],
+              ),
+            );
+          }
+        }
+
+        // Handle resize (debounced to avoid layout thrashing)
+        let resizeTimer: ReturnType<typeof setTimeout>;
+        const onResize = () => {
+          clearTimeout(resizeTimer);
+          resizeTimer = setTimeout(() => {
+            const r = container.getBoundingClientRect();
+            renderer.setSize(r.width, r.height);
+            // Update focal length on resize to maintain FOV
+            if (fov) {
+              const fl = fovToFocalLength(fov, r.height);
+              camera.data.fx = fl;
+              camera.data.fy = fl;
+            }
+          }, 100);
+        };
+        window.addEventListener("resize", onResize);
+
+        // Load splat files with progress tracking
+        if (splats && splats.length > 0) {
+          const totalSplats = splats.length;
+          for (let i = 0; i < totalSplats; i++) {
+            if (cancelled) return;
+            const entry = splats[i]!;
+            const splatObject = await SPLAT.Loader.LoadAsync(
+              entry.src,
+              scene,
+              (p: number) => {
+                if (!cancelled) {
+                  const overallProgress = (i + p) / totalSplats;
+                  // Use Math.max to prevent progress bar from jumping backwards
+                  setProgress((prev) => Math.max(prev, overallProgress));
+                }
+              },
+            );
+
+            // Apply per-splat transforms
+            if (entry.position) {
+              splatObject.position = new SPLAT.Vector3(
+                entry.position[0],
+                entry.position[1],
+                entry.position[2],
+              );
+            }
+            if (entry.rotation) {
+              splatObject.rotation = eulerToQuaternion(entry.rotation);
+            }
+            if (entry.scale) {
+              splatObject.scale = new SPLAT.Vector3(
+                entry.scale[0],
+                entry.scale[1],
+                entry.scale[2],
+              );
+            }
+            if (entry.visible !== undefined) {
+              splatObject.visible = entry.visible;
+            }
+          }
+        }
+
+        if (cancelled) {
+          controls?.dispose();
+          renderer.dispose();
+          if (renderer.canvas.parentElement) {
+            renderer.canvas.parentElement.removeChild(renderer.canvas);
+          }
+          window.removeEventListener("resize", onResize);
+          return;
+        }
+
+        setIsLoading(false);
+        setError(null);
+
+        // Render loop with optional auto-rotation
+        let animationId: number;
+        let lastTime = performance.now();
+        const frame = () => {
+          const now = performance.now();
+          const dt = (now - lastTime) / 1000;
+          lastTime = now;
+
+          if (autoRotate) {
+            // Rotate the camera around the camera target (not world origin)
+            const speed = autoRotateSpeed * 0.5;
+            const angle = speed * dt;
+            const pos = camera.position;
+            const target = cameraTarget
+              ? new SPLAT.Vector3(
+                  cameraTarget[0],
+                  cameraTarget[1],
+                  cameraTarget[2],
+                )
+              : new SPLAT.Vector3(0, 0, 0);
+            const dx = pos.x - target.x;
+            const dz = pos.z - target.z;
+            const cos = Math.cos(angle);
+            const sin = Math.sin(angle);
+            camera.position = new SPLAT.Vector3(
+              target.x + dx * cos - dz * sin,
+              pos.y,
+              target.z + dx * sin + dz * cos,
+            );
+          }
+
+          controls?.update();
+          renderer.render(scene, camera);
+          animationId = requestAnimationFrame(frame);
+        };
+        animationId = requestAnimationFrame(frame);
+
+        // Store cleanup function
+        cleanupRef.current = () => {
+          cancelAnimationFrame(animationId);
+          clearTimeout(resizeTimer);
+          window.removeEventListener("resize", onResize);
+          controls?.dispose();
+          renderer.dispose();
+          if (renderer.canvas.parentElement) {
+            renderer.canvas.parentElement.removeChild(renderer.canvas);
+          }
+        };
+      } catch (err) {
+        if (!cancelled) {
+          setError(
+            err instanceof Error ? err.message : "Failed to initialize viewer",
+          );
+          setIsLoading(false);
+        }
+      }
+    }
+
+    init();
+
+    return () => {
+      cancelled = true;
+      if (cleanupRef.current) {
+        cleanupRef.current();
+        cleanupRef.current = null;
+      }
+    };
+  }, [
+    splats,
+    enableControls,
+    autoRotate,
+    autoRotateSpeed,
+    cameraPosition,
+    cameraTarget,
+    fov,
+  ]);
+
+  return (
+    <div
+      style={{
+        width,
+        height,
+        position: "relative",
+        overflow: "hidden",
+        background: backgroundColor,
+      }}
+    >
+      <div
+        ref={containerRef}
+        style={{ position: "relative", width: "100%", height: "100%" }}
+      />
+      {isLoading &&
+        (loadingIndicator ?? (
+          <ProgressIndicator
+            progress={progress}
+            backgroundColor={progressBackgroundColor}
+            barColor={progressBarColor}
+            trackColor={progressTrackColor}
+            textColor={progressTextColor}
+          />
+        ))}
+      {error && (
+        <div
+          role="alert"
+          style={{
+            position: "absolute",
+            inset: 0,
+            display: "flex",
+            alignItems: "center",
+            justifyContent: "center",
+            background: backgroundColor,
+            color: "#ff4444",
+            fontFamily: "ui-monospace, monospace",
+            fontSize: 12,
+            padding: 20,
+            textAlign: "center",
+          }}
+        >
+          {error}
+        </div>
+      )}
+      {children}
+    </div>
+  );
+}

+ 79 - 0
examples/gsplat/app/gsplat.d.ts

@@ -0,0 +1,79 @@
+declare module "gsplat" {
+  export class Vector3 {
+    constructor(x?: number, y?: number, z?: number);
+    x: number;
+    y: number;
+    z: number;
+  }
+
+  export class Quaternion {
+    constructor(x?: number, y?: number, z?: number, w?: number);
+    x: number;
+    y: number;
+    z: number;
+    w: number;
+  }
+
+  export class Splat {
+    get position(): Vector3;
+    set position(position: Vector3);
+    get rotation(): Quaternion;
+    set rotation(rotation: Quaternion);
+    get scale(): Vector3;
+    set scale(scale: Vector3);
+    get visible(): boolean;
+    set visible(visible: boolean);
+  }
+
+  export class Scene {
+    constructor();
+  }
+
+  export class Camera {
+    constructor();
+    get position(): Vector3;
+    set position(position: Vector3);
+    get rotation(): Quaternion;
+    set rotation(rotation: Quaternion);
+    data: CameraData;
+  }
+
+  export class CameraData {
+    get fx(): number;
+    set fx(fx: number);
+    get fy(): number;
+    set fy(fy: number);
+  }
+
+  export class WebGLRenderer {
+    constructor(canvas?: HTMLCanvasElement);
+    canvas: HTMLCanvasElement;
+    setSize(width: number, height: number): void;
+    render(scene: Scene, camera: Camera): void;
+    dispose(): void;
+  }
+
+  export class OrbitControls {
+    constructor(camera: Camera, canvas: HTMLCanvasElement);
+    setCameraTarget(target: Vector3): void;
+    minAngle: number;
+    maxAngle: number;
+    minZoom: number;
+    maxZoom: number;
+    orbitSpeed: number;
+    panSpeed: number;
+    zoomSpeed: number;
+    dampening: number;
+    update(): void;
+    dispose(): void;
+  }
+
+  export class Loader {
+    static LoadAsync(
+      url: string,
+      scene: Scene,
+      onProgress?: (progress: number) => void,
+      useCache?: boolean,
+    ): Promise<Splat>;
+  }
+}

+ 21 - 0
examples/gsplat/app/layout.tsx

@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+  title: "json-render Gaussian Splatting Example",
+  description:
+    "Experimental gaussian splat viewer demo using gsplat.js (no Three.js)",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>
+        {children}
+      </body>
+    </html>
+  );
+}

+ 460 - 0
examples/gsplat/app/page.tsx

@@ -0,0 +1,460 @@
+"use client";
+
+import {
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+  useSyncExternalStore,
+} from "react";
+import { GaussianSplatViewer } from "./GaussianSplatViewer";
+import { scenes } from "./scenes";
+
+function highlightJson(json: string): string {
+  return json.replace(
+    /("(?:\\.|[^"\\])*")\s*(:)|("(?:\\.|[^"\\])*")|([-+]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b|\bnull\b)|([{}[\]:,])/g,
+    (_, key, colon, str, num, lit, punct) => {
+      if (key) return `<span style="color:#FF4D8D">${key}</span>${colon}`;
+      if (str) return `<span style="color:#00CA50">${str}</span>`;
+      if (num) return `<span style="color:#47A8FF">${num}</span>`;
+      if (lit) return `<span style="color:#47A8FF">${lit}</span>`;
+      if (punct) return `<span style="color:#666">${punct}</span>`;
+      return _;
+    },
+  );
+}
+
+const MOBILE_BREAKPOINT = 768;
+
+function subscribeToResize(cb: () => void) {
+  window.addEventListener("resize", cb);
+  return () => window.removeEventListener("resize", cb);
+}
+
+function getIsMobile() {
+  return typeof window !== "undefined"
+    ? window.innerWidth < MOBILE_BREAKPOINT
+    : false;
+}
+
+function useIsMobile() {
+  return useSyncExternalStore(subscribeToResize, getIsMobile, () => false);
+}
+
+const LIST_WIDTH = 220;
+const JSON_WIDTH = 380;
+const HEADER_HEIGHT = 40;
+
+const headerStyle: React.CSSProperties = {
+  height: HEADER_HEIGHT,
+  display: "flex",
+  alignItems: "center",
+  padding: "0 16px",
+  fontSize: 11,
+  fontWeight: 600,
+  color: "#555",
+  letterSpacing: "0.08em",
+  textTransform: "uppercase",
+  fontFamily: "ui-monospace, monospace",
+  borderBottom: "1px solid #1e1e1e",
+  flexShrink: 0,
+  whiteSpace: "nowrap",
+  overflow: "hidden",
+  textOverflow: "ellipsis",
+  boxSizing: "border-box",
+};
+
+function SplatViewer({ sceneIndex }: { sceneIndex: number }) {
+  const scene = scenes[sceneIndex]!;
+  const v = scene.viewer;
+
+  return (
+    <GaussianSplatViewer
+      width="100%"
+      height="100%"
+      backgroundColor={v.backgroundColor ?? "#0a0a0a"}
+      controls={v.controls ?? true}
+      autoRotate={v.autoRotate ?? true}
+      autoRotateSpeed={v.autoRotateSpeed ?? 0.5}
+      cameraPosition={v.cameraPosition ?? [0, 2, 5]}
+      cameraTarget={v.cameraTarget ?? [0, 0, 0]}
+      fov={v.fov ?? 50}
+      splats={scene.splats}
+    />
+  );
+}
+
+function SceneListItem({
+  scene,
+  index,
+  isSelected,
+  onSelect,
+}: {
+  scene: (typeof scenes)[number];
+  index: number;
+  isSelected: boolean;
+  onSelect: (i: number) => void;
+}) {
+  return (
+    <button
+      role="option"
+      aria-selected={isSelected}
+      onClick={() => onSelect(index)}
+      style={{
+        display: "block",
+        width: "100%",
+        padding: "8px 16px",
+        fontSize: 13,
+        border: "none",
+        textAlign: "left",
+        background: isSelected ? "rgba(255,255,255,0.08)" : "transparent",
+        color: isSelected ? "#fff" : "#888",
+        fontWeight: isSelected ? 500 : 400,
+        cursor: "pointer",
+        borderLeft: isSelected ? "2px solid #fff" : "2px solid transparent",
+        fontFamily: "inherit",
+      }}
+    >
+      {scene.name}
+    </button>
+  );
+}
+
+function DesktopLayout() {
+  const [selectedIndex, setSelectedIndex] = useState(0);
+  const selected = scenes[selectedIndex]!;
+
+  return (
+    <div style={{ height: "100dvh", display: "flex", background: "#0a0a0a" }}>
+      <nav
+        aria-label="Splat scenes"
+        style={{
+          width: LIST_WIDTH,
+          flexShrink: 0,
+          display: "flex",
+          flexDirection: "column",
+          borderRight: "1px solid #1e1e1e",
+          background: "#0f0f0f",
+        }}
+      >
+        <div style={headerStyle}>Splat Scenes</div>
+        <div
+          role="listbox"
+          aria-label="Scene list"
+          style={{ flex: 1, overflowY: "auto", padding: "6px 0" }}
+        >
+          {scenes.map((scene, i) => (
+            <SceneListItem
+              key={scene.name}
+              scene={scene}
+              index={i}
+              isSelected={i === selectedIndex}
+              onSelect={setSelectedIndex}
+            />
+          ))}
+        </div>
+      </nav>
+
+      <main
+        style={{
+          flex: 1,
+          display: "flex",
+          flexDirection: "column",
+          minWidth: 0,
+        }}
+      >
+        <div style={headerStyle}>{selected.description}</div>
+        <div style={{ flex: 1, background: "#000", position: "relative" }}>
+          <SplatViewer key={selectedIndex} sceneIndex={selectedIndex} />
+        </div>
+      </main>
+
+      <aside
+        aria-label="Spec JSON"
+        style={{
+          width: JSON_WIDTH,
+          flexShrink: 0,
+          display: "flex",
+          flexDirection: "column",
+          borderLeft: "1px solid #1e1e1e",
+          background: "#0d0d0d",
+        }}
+      >
+        <div style={headerStyle}>Spec JSON</div>
+        <pre
+          style={{
+            flex: 1,
+            margin: 0,
+            padding: 14,
+            overflowY: "auto",
+            overflowX: "auto",
+            fontSize: 11,
+            lineHeight: 1.6,
+            fontFamily:
+              "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
+            color: "#EDEDED",
+            tabSize: 2,
+          }}
+          dangerouslySetInnerHTML={{
+            __html: highlightJson(
+              JSON.stringify(
+                { spec: selected.spec, splats: selected.splats },
+                null,
+                2,
+              ),
+            ),
+          }}
+        />
+      </aside>
+    </div>
+  );
+}
+
+function MobileLayout() {
+  const [selectedIndex, setSelectedIndex] = useState(0);
+  const [showJson, setShowJson] = useState(false);
+  const [showScenes, setShowScenes] = useState(false);
+  const selected = scenes[selectedIndex]!;
+  const scenePanelRef = useRef<HTMLDivElement>(null);
+  const jsonPanelRef = useRef<HTMLDivElement>(null);
+
+  const closeScenes = useCallback(() => setShowScenes(false), []);
+  const closeJson = useCallback(() => setShowJson(false), []);
+
+  // Close overlays on Escape
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        if (showScenes) closeScenes();
+        if (showJson) closeJson();
+      }
+    };
+    window.addEventListener("keydown", onKeyDown);
+    return () => window.removeEventListener("keydown", onKeyDown);
+  }, [showScenes, showJson, closeScenes, closeJson]);
+
+  // Focus the panel when it opens
+  useEffect(() => {
+    if (showScenes) scenePanelRef.current?.focus();
+  }, [showScenes]);
+  useEffect(() => {
+    if (showJson) jsonPanelRef.current?.focus();
+  }, [showJson]);
+
+  return (
+    <div
+      style={{
+        height: "100dvh",
+        display: "flex",
+        flexDirection: "column",
+        background: "#0a0a0a",
+        overflow: "hidden",
+      }}
+    >
+      <header
+        style={{
+          height: 48,
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "space-between",
+          padding: "0 12px",
+          borderBottom: "1px solid #1e1e1e",
+          background: "#0f0f0f",
+          flexShrink: 0,
+          gap: 8,
+        }}
+      >
+        <button
+          aria-label={`Scene: ${selected.name}. Open scene list`}
+          aria-expanded={showScenes}
+          onClick={() => {
+            setShowScenes((v) => !v);
+            setShowJson(false);
+          }}
+          style={{
+            background: showScenes ? "rgba(255,255,255,0.1)" : "transparent",
+            border: "1px solid #333",
+            borderRadius: 6,
+            color: "#ccc",
+            fontSize: 12,
+            fontWeight: 500,
+            padding: "6px 10px",
+            cursor: "pointer",
+            fontFamily: "inherit",
+            whiteSpace: "nowrap",
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+            maxWidth: "50%",
+          }}
+        >
+          {selected.name}
+        </button>
+
+        <span
+          style={{
+            flex: 1,
+            fontSize: 10,
+            color: "#555",
+            textAlign: "center",
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+            whiteSpace: "nowrap",
+            fontFamily: "ui-monospace, monospace",
+            textTransform: "uppercase",
+            letterSpacing: "0.06em",
+          }}
+        >
+          {selected.description}
+        </span>
+
+        <button
+          aria-label="Toggle JSON panel"
+          aria-expanded={showJson}
+          onClick={() => {
+            setShowJson((v) => !v);
+            setShowScenes(false);
+          }}
+          style={{
+            background: showJson ? "rgba(255,255,255,0.1)" : "transparent",
+            border: "1px solid #333",
+            borderRadius: 6,
+            color: "#ccc",
+            fontSize: 11,
+            fontWeight: 500,
+            padding: "6px 10px",
+            cursor: "pointer",
+            fontFamily: "ui-monospace, monospace",
+            whiteSpace: "nowrap",
+            letterSpacing: "0.04em",
+          }}
+        >
+          JSON
+        </button>
+      </header>
+
+      <div style={{ flex: 1, position: "relative", minHeight: 0 }}>
+        <SplatViewer key={selectedIndex} sceneIndex={selectedIndex} />
+
+        {showScenes && (
+          <>
+            <div
+              onClick={closeScenes}
+              style={{
+                position: "absolute",
+                inset: 0,
+                background: "rgba(0,0,0,0.5)",
+                zIndex: 10,
+              }}
+            />
+            <div
+              ref={scenePanelRef}
+              role="dialog"
+              aria-modal="true"
+              aria-label="Scene list"
+              tabIndex={-1}
+              style={{
+                position: "absolute",
+                top: 0,
+                left: 0,
+                bottom: 0,
+                width: "75%",
+                maxWidth: 280,
+                background: "#0f0f0f",
+                borderRight: "1px solid #1e1e1e",
+                zIndex: 11,
+                display: "flex",
+                flexDirection: "column",
+                overflowY: "auto",
+              }}
+            >
+              <div style={{ ...headerStyle, height: 36 }}>Splat Scenes</div>
+              <div
+                role="listbox"
+                aria-label="Scene list"
+                style={{ padding: "6px 0" }}
+              >
+                {scenes.map((scene, i) => (
+                  <SceneListItem
+                    key={scene.name}
+                    scene={scene}
+                    index={i}
+                    isSelected={i === selectedIndex}
+                    onSelect={(idx) => {
+                      setSelectedIndex(idx);
+                      closeScenes();
+                    }}
+                  />
+                ))}
+              </div>
+            </div>
+          </>
+        )}
+
+        {showJson && (
+          <>
+            <div
+              onClick={closeJson}
+              style={{
+                position: "absolute",
+                inset: 0,
+                background: "rgba(0,0,0,0.5)",
+                zIndex: 10,
+              }}
+            />
+            <div
+              ref={jsonPanelRef}
+              role="dialog"
+              aria-modal="true"
+              aria-label="Spec JSON"
+              tabIndex={-1}
+              style={{
+                position: "absolute",
+                top: 0,
+                right: 0,
+                bottom: 0,
+                width: "85%",
+                maxWidth: 400,
+                background: "#0d0d0d",
+                borderLeft: "1px solid #1e1e1e",
+                zIndex: 11,
+                display: "flex",
+                flexDirection: "column",
+              }}
+            >
+              <div style={{ ...headerStyle, height: 36 }}>Spec JSON</div>
+              <pre
+                style={{
+                  flex: 1,
+                  margin: 0,
+                  padding: 14,
+                  overflowY: "auto",
+                  overflowX: "auto",
+                  fontSize: 11,
+                  lineHeight: 1.6,
+                  fontFamily:
+                    "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
+                  color: "#EDEDED",
+                  tabSize: 2,
+                }}
+                dangerouslySetInnerHTML={{
+                  __html: highlightJson(
+                    JSON.stringify(
+                      { spec: selected.spec, splats: selected.splats },
+                      null,
+                      2,
+                    ),
+                  ),
+                }}
+              />
+            </div>
+          </>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export default function Page() {
+  const isMobile = useIsMobile();
+  return isMobile ? <MobileLayout /> : <DesktopLayout />;
+}

+ 24 - 0
examples/gsplat/app/scenes/_helpers.ts

@@ -0,0 +1,24 @@
+export type ViewerConfig = {
+  backgroundColor?: string;
+  controls?: boolean;
+  autoRotate?: boolean;
+  autoRotateSpeed?: number;
+  cameraPosition?: [number, number, number];
+  cameraTarget?: [number, number, number];
+  fov?: number;
+};
+
+// The "spec" here is just JSON shown next to the viewer for illustration —
+// the example doesn't actually render specs, so we keep it as a plain object.
+export type Scene = {
+  name: string;
+  description: string;
+  spec: Record<string, unknown>;
+  viewer: ViewerConfig;
+  splats: Array<{
+    src: string;
+    position?: [number, number, number];
+    rotation?: [number, number, number];
+    scale?: [number, number, number];
+  }>;
+};

+ 41 - 0
examples/gsplat/app/scenes/bicycle.ts

@@ -0,0 +1,41 @@
+import type { Scene } from "./_helpers";
+
+export const bicycle: Scene = {
+  name: "Bicycle",
+  description: "A bicycle scene captured with gaussian splatting",
+  spec: {
+    root: "viewer",
+    elements: {
+      viewer: {
+        type: "GaussianSplatViewer",
+        props: {
+          width: "100%",
+          height: "100vh",
+          backgroundColor: "#0a0a0a",
+          controls: true,
+          autoRotate: true,
+          autoRotateSpeed: 0.4,
+          cameraPosition: [3, 2, 5],
+          cameraTarget: [0, 0, 0],
+          fov: 55,
+        },
+        children: [],
+      },
+    },
+  },
+  viewer: {
+    backgroundColor: "#0a0a0a",
+    controls: true,
+    autoRotate: true,
+    autoRotateSpeed: 0.4,
+    cameraPosition: [3, 2, 5],
+    cameraTarget: [0, 0, 0],
+    fov: 55,
+  },
+  splats: [
+    {
+      src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bicycle/bicycle-7k.splat",
+      position: [0, 0, 0],
+    },
+  ],
+};

+ 41 - 0
examples/gsplat/app/scenes/bonsai.ts

@@ -0,0 +1,41 @@
+import type { Scene } from "./_helpers";
+
+export const bonsai: Scene = {
+  name: "Bonsai",
+  description: "A bonsai tree captured with gaussian splatting",
+  spec: {
+    root: "viewer",
+    elements: {
+      viewer: {
+        type: "GaussianSplatViewer",
+        props: {
+          width: "100%",
+          height: "100vh",
+          backgroundColor: "#0a0a0a",
+          controls: true,
+          autoRotate: true,
+          autoRotateSpeed: 0.5,
+          cameraPosition: [0, 1.5, 3],
+          cameraTarget: [0, 0.5, 0],
+          fov: 50,
+        },
+        children: [],
+      },
+    },
+  },
+  viewer: {
+    backgroundColor: "#0a0a0a",
+    controls: true,
+    autoRotate: true,
+    autoRotateSpeed: 0.5,
+    cameraPosition: [0, 1.5, 3],
+    cameraTarget: [0, 0.5, 0],
+    fov: 50,
+  },
+  splats: [
+    {
+      src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
+      position: [0, 0, 0],
+    },
+  ],
+};

+ 41 - 0
examples/gsplat/app/scenes/garden.ts

@@ -0,0 +1,41 @@
+import type { Scene } from "./_helpers";
+
+export const garden: Scene = {
+  name: "Garden",
+  description: "A garden scene captured with gaussian splatting",
+  spec: {
+    root: "viewer",
+    elements: {
+      viewer: {
+        type: "GaussianSplatViewer",
+        props: {
+          width: "100%",
+          height: "100vh",
+          backgroundColor: "#0a0a0a",
+          controls: true,
+          autoRotate: true,
+          autoRotateSpeed: 0.3,
+          cameraPosition: [2, 2, 5],
+          cameraTarget: [0, 0, 0],
+          fov: 60,
+        },
+        children: [],
+      },
+    },
+  },
+  viewer: {
+    backgroundColor: "#0a0a0a",
+    controls: true,
+    autoRotate: true,
+    autoRotateSpeed: 0.3,
+    cameraPosition: [2, 2, 5],
+    cameraTarget: [0, 0, 0],
+    fov: 60,
+  },
+  splats: [
+    {
+      src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/garden/garden-7k.splat",
+      position: [0, 0, 0],
+    },
+  ],
+};

+ 17 - 0
examples/gsplat/app/scenes/index.ts

@@ -0,0 +1,17 @@
+import type { Scene } from "./_helpers";
+import { bonsai } from "./bonsai";
+import { garden } from "./garden";
+import { bicycle } from "./bicycle";
+import { kitchen } from "./kitchen";
+import { stump } from "./stump";
+import { multiOffset } from "./multi-offset";
+export type { Scene };
+
+export const scenes: Scene[] = [
+  bonsai,
+  garden,
+  bicycle,
+  kitchen,
+  stump,
+  multiOffset,
+];

+ 41 - 0
examples/gsplat/app/scenes/kitchen.ts

@@ -0,0 +1,41 @@
+import type { Scene } from "./_helpers";
+
+export const kitchen: Scene = {
+  name: "Kitchen",
+  description: "An indoor kitchen scan showing spatial depth and lighting",
+  spec: {
+    root: "viewer",
+    elements: {
+      viewer: {
+        type: "GaussianSplatViewer",
+        props: {
+          width: "100%",
+          height: "100vh",
+          backgroundColor: "#0d0d0d",
+          controls: true,
+          autoRotate: true,
+          autoRotateSpeed: 0.3,
+          cameraPosition: [2, 2, 4],
+          cameraTarget: [0, 0.5, 0],
+          fov: 55,
+        },
+        children: [],
+      },
+    },
+  },
+  viewer: {
+    backgroundColor: "#0d0d0d",
+    controls: true,
+    autoRotate: true,
+    autoRotateSpeed: 0.3,
+    cameraPosition: [2, 2, 4],
+    cameraTarget: [0, 0.5, 0],
+    fov: 55,
+  },
+  splats: [
+    {
+      src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/kitchen/kitchen-7k.splat",
+      position: [0, 0, 0],
+    },
+  ],
+};

+ 47 - 0
examples/gsplat/app/scenes/multi-offset.ts

@@ -0,0 +1,47 @@
+import type { Scene } from "./_helpers";
+
+export const multiOffset: Scene = {
+  name: "Multi-Splat (Offset)",
+  description:
+    "Two splats placed away from the origin — demonstrates off-center orbit",
+  spec: {
+    root: "viewer",
+    elements: {
+      viewer: {
+        type: "GaussianSplatViewer",
+        props: {
+          width: "100%",
+          height: "100vh",
+          backgroundColor: "#0a0a0a",
+          controls: true,
+          autoRotate: true,
+          autoRotateSpeed: 0.4,
+          cameraPosition: [5, 3, 8],
+          cameraTarget: [3, 0.5, 3],
+          fov: 50,
+        },
+        children: [],
+      },
+    },
+  },
+  viewer: {
+    backgroundColor: "#0a0a0a",
+    controls: true,
+    autoRotate: true,
+    autoRotateSpeed: 0.4,
+    cameraPosition: [5, 3, 8],
+    cameraTarget: [3, 0.5, 3],
+    fov: 50,
+  },
+  splats: [
+    {
+      src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
+      position: [1.5, 0, 3],
+    },
+    {
+      src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/stump/stump-7k.splat",
+      position: [4.5, 0, 3],
+      scale: [0.8, 0.8, 0.8],
+    },
+  ],
+};

+ 41 - 0
examples/gsplat/app/scenes/stump.ts

@@ -0,0 +1,41 @@
+import type { Scene } from "./_helpers";
+
+export const stump: Scene = {
+  name: "Stump",
+  description: "A tree stump with organic textures and natural detail",
+  spec: {
+    root: "viewer",
+    elements: {
+      viewer: {
+        type: "GaussianSplatViewer",
+        props: {
+          width: "100%",
+          height: "100vh",
+          backgroundColor: "#080808",
+          controls: true,
+          autoRotate: true,
+          autoRotateSpeed: 0.6,
+          cameraPosition: [1.5, 1.5, 3],
+          cameraTarget: [0, 0.3, 0],
+          fov: 45,
+        },
+        children: [],
+      },
+    },
+  },
+  viewer: {
+    backgroundColor: "#080808",
+    controls: true,
+    autoRotate: true,
+    autoRotateSpeed: 0.6,
+    cameraPosition: [1.5, 1.5, 3],
+    cameraTarget: [0, 0.3, 0],
+    fov: 45,
+  },
+  splats: [
+    {
+      src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/stump/stump-7k.splat",
+      position: [0, 0, 0],
+    },
+  ],
+};

+ 11 - 0
examples/gsplat/eslint.config.js

@@ -0,0 +1,11 @@
+import { nextJsConfig } from "@internal/eslint-config/next-js";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+  ...nextJsConfig,
+  {
+    rules: {
+      "react/prop-types": "off",
+    },
+  },
+];

+ 6 - 0
examples/gsplat/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 5 - 0
examples/gsplat/next.config.ts

@@ -0,0 +1,5 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {};
+
+export default nextConfig;

+ 29 - 0
examples/gsplat/package.json

@@ -0,0 +1,29 @@
+{
+  "name": "example-gsplat",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
+    "dev": "portless gsplat-demo.json-render next dev --turbopack",
+    "dev:local": "next dev --turbopack",
+    "build": "next build",
+    "start": "next start",
+    "lint": "eslint --max-warnings 0",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "gsplat": "^1.2.9",
+    "next": "16.1.6",
+    "react": "19.2.4",
+    "react-dom": "19.2.4"
+  },
+  "devDependencies": {
+    "@internal/eslint-config": "workspace:*",
+    "@types/node": "^22.10.0",
+    "@types/react": "19.2.3",
+    "@types/react-dom": "19.2.3",
+    "eslint": "^9.39.1",
+    "typescript": "^5.7.2"
+  }
+}

+ 13 - 0
examples/gsplat/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../packages/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "declaration": false,
+    "declarationMap": false,
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

+ 21 - 0
examples/react-three-fiber-gsplat/app/layout.tsx

@@ -0,0 +1,21 @@
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+  title: "json-render R3F + Gaussian Splatting Example",
+  description:
+    "Gaussian splats composed with 3D primitives using @json-render/react-three-fiber",
+};
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  return (
+    <html lang="en">
+      <body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>
+        {children}
+      </body>
+    </html>
+  );
+}

+ 408 - 0
examples/react-three-fiber-gsplat/app/page.tsx

@@ -0,0 +1,408 @@
+"use client";
+
+import { useState, useSyncExternalStore } from "react";
+import { defineCatalog } from "@json-render/core";
+import { schema, defineRegistry } from "@json-render/react";
+import {
+  threeComponentDefinitions,
+  threeComponents,
+  ThreeCanvas,
+} from "@json-render/react-three-fiber";
+import { scenes } from "./scenes";
+
+const catalog = defineCatalog(schema, {
+  components: {
+    ...threeComponentDefinitions,
+  },
+  actions: {},
+});
+
+const { registry } = defineRegistry(catalog, {
+  components: {
+    ...threeComponents,
+  },
+});
+
+function highlightJson(json: string): string {
+  return json.replace(
+    /("(?:\\.|[^"\\])*")\s*(:)|("(?:\\.|[^"\\])*")|([-+]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b|\bnull\b)|([{}[\]:,])/g,
+    (_, key, colon, str, num, lit, punct) => {
+      if (key) return `<span style="color:#FF4D8D">${key}</span>${colon}`;
+      if (str) return `<span style="color:#00CA50">${str}</span>`;
+      if (num) return `<span style="color:#47A8FF">${num}</span>`;
+      if (lit) return `<span style="color:#47A8FF">${lit}</span>`;
+      if (punct) return `<span style="color:#666">${punct}</span>`;
+      return _;
+    },
+  );
+}
+
+const MOBILE_BREAKPOINT = 768;
+
+function subscribeToResize(cb: () => void) {
+  window.addEventListener("resize", cb);
+  return () => window.removeEventListener("resize", cb);
+}
+
+function getIsMobile() {
+  return typeof window !== "undefined"
+    ? window.innerWidth < MOBILE_BREAKPOINT
+    : false;
+}
+
+function useIsMobile() {
+  return useSyncExternalStore(subscribeToResize, getIsMobile, () => false);
+}
+
+const LIST_WIDTH = 220;
+const JSON_WIDTH = 380;
+const HEADER_HEIGHT = 40;
+
+const headerStyle: React.CSSProperties = {
+  height: HEADER_HEIGHT,
+  display: "flex",
+  alignItems: "center",
+  padding: "0 16px",
+  fontSize: 11,
+  fontWeight: 600,
+  color: "#555",
+  letterSpacing: "0.08em",
+  textTransform: "uppercase",
+  fontFamily: "ui-monospace, monospace",
+  borderBottom: "1px solid #1e1e1e",
+  flexShrink: 0,
+  whiteSpace: "nowrap",
+  overflow: "hidden",
+  textOverflow: "ellipsis",
+  boxSizing: "border-box",
+};
+
+function DesktopLayout() {
+  const [selectedIndex, setSelectedIndex] = useState(0);
+  const selected = scenes[selectedIndex]!;
+
+  return (
+    <div style={{ height: "100vh", display: "flex", background: "#0a0a0a" }}>
+      <div
+        style={{
+          width: LIST_WIDTH,
+          flexShrink: 0,
+          display: "flex",
+          flexDirection: "column",
+          borderRight: "1px solid #1e1e1e",
+          background: "#0f0f0f",
+        }}
+      >
+        <div style={headerStyle}>Scenes</div>
+        <div style={{ flex: 1, overflowY: "auto", padding: "6px 0" }}>
+          {scenes.map((scene, i) => (
+            <button
+              key={scene.name}
+              onClick={() => setSelectedIndex(i)}
+              style={{
+                display: "block",
+                width: "100%",
+                padding: "8px 16px",
+                fontSize: 13,
+                border: "none",
+                textAlign: "left",
+                background:
+                  i === selectedIndex
+                    ? "rgba(255,255,255,0.08)"
+                    : "transparent",
+                color: i === selectedIndex ? "#fff" : "#888",
+                fontWeight: i === selectedIndex ? 500 : 400,
+                cursor: "pointer",
+                borderLeft:
+                  i === selectedIndex
+                    ? "2px solid #fff"
+                    : "2px solid transparent",
+                fontFamily: "inherit",
+              }}
+            >
+              {scene.name}
+            </button>
+          ))}
+        </div>
+      </div>
+
+      <div
+        style={{
+          flex: 1,
+          display: "flex",
+          flexDirection: "column",
+          minWidth: 0,
+        }}
+      >
+        <div style={headerStyle}>{selected.description}</div>
+        <div style={{ flex: 1, background: "#000", position: "relative" }}>
+          <ThreeCanvas
+            key={selectedIndex}
+            spec={selected.spec}
+            registry={registry}
+            shadows
+            camera={{ position: [0, 0, 5], fov: 50 }}
+            style={{ width: "100%", height: "100%" }}
+          />
+        </div>
+      </div>
+
+      <div
+        style={{
+          width: JSON_WIDTH,
+          flexShrink: 0,
+          display: "flex",
+          flexDirection: "column",
+          borderLeft: "1px solid #1e1e1e",
+          background: "#0d0d0d",
+        }}
+      >
+        <div style={headerStyle}>Spec JSON</div>
+        <pre
+          style={{
+            flex: 1,
+            margin: 0,
+            padding: 14,
+            overflowY: "auto",
+            overflowX: "auto",
+            fontSize: 11,
+            lineHeight: 1.6,
+            fontFamily:
+              "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
+            color: "#EDEDED",
+            tabSize: 2,
+          }}
+          dangerouslySetInnerHTML={{
+            __html: highlightJson(JSON.stringify(selected.spec, null, 2)),
+          }}
+        />
+      </div>
+    </div>
+  );
+}
+
+function MobileLayout() {
+  const [selectedIndex, setSelectedIndex] = useState(0);
+  const [showJson, setShowJson] = useState(false);
+  const [showScenes, setShowScenes] = useState(false);
+  const selected = scenes[selectedIndex]!;
+
+  return (
+    <div
+      style={{
+        height: "100dvh",
+        display: "flex",
+        flexDirection: "column",
+        background: "#0a0a0a",
+        overflow: "hidden",
+      }}
+    >
+      <div
+        style={{
+          height: 48,
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "space-between",
+          padding: "0 12px",
+          borderBottom: "1px solid #1e1e1e",
+          background: "#0f0f0f",
+          flexShrink: 0,
+          gap: 8,
+        }}
+      >
+        <button
+          onClick={() => {
+            setShowScenes((v) => !v);
+            setShowJson(false);
+          }}
+          style={{
+            background: showScenes ? "rgba(255,255,255,0.1)" : "transparent",
+            border: "1px solid #333",
+            borderRadius: 6,
+            color: "#ccc",
+            fontSize: 12,
+            fontWeight: 500,
+            padding: "6px 10px",
+            cursor: "pointer",
+            fontFamily: "inherit",
+            whiteSpace: "nowrap",
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+            maxWidth: "50%",
+          }}
+        >
+          {selected.name}
+        </button>
+
+        <span
+          style={{
+            flex: 1,
+            fontSize: 10,
+            color: "#555",
+            textAlign: "center",
+            overflow: "hidden",
+            textOverflow: "ellipsis",
+            whiteSpace: "nowrap",
+            fontFamily: "ui-monospace, monospace",
+            textTransform: "uppercase",
+            letterSpacing: "0.06em",
+          }}
+        >
+          {selected.description}
+        </span>
+
+        <button
+          onClick={() => {
+            setShowJson((v) => !v);
+            setShowScenes(false);
+          }}
+          style={{
+            background: showJson ? "rgba(255,255,255,0.1)" : "transparent",
+            border: "1px solid #333",
+            borderRadius: 6,
+            color: "#ccc",
+            fontSize: 11,
+            fontWeight: 500,
+            padding: "6px 10px",
+            cursor: "pointer",
+            fontFamily: "ui-monospace, monospace",
+            whiteSpace: "nowrap",
+            letterSpacing: "0.04em",
+          }}
+        >
+          JSON
+        </button>
+      </div>
+
+      <div style={{ flex: 1, position: "relative", minHeight: 0 }}>
+        <ThreeCanvas
+          key={selectedIndex}
+          spec={selected.spec}
+          registry={registry}
+          shadows
+          camera={{ position: [0, 0, 5], fov: 50 }}
+          style={{ width: "100%", height: "100%" }}
+        />
+
+        {showScenes && (
+          <>
+            <div
+              onClick={() => setShowScenes(false)}
+              style={{
+                position: "absolute",
+                inset: 0,
+                background: "rgba(0,0,0,0.5)",
+                zIndex: 10,
+              }}
+            />
+            <div
+              style={{
+                position: "absolute",
+                top: 0,
+                left: 0,
+                bottom: 0,
+                width: "75%",
+                maxWidth: 280,
+                background: "#0f0f0f",
+                borderRight: "1px solid #1e1e1e",
+                zIndex: 11,
+                display: "flex",
+                flexDirection: "column",
+                overflowY: "auto",
+              }}
+            >
+              <div style={{ ...headerStyle, height: 36 }}>Scenes</div>
+              <div style={{ padding: "6px 0" }}>
+                {scenes.map((scene, i) => (
+                  <button
+                    key={scene.name}
+                    onClick={() => {
+                      setSelectedIndex(i);
+                      setShowScenes(false);
+                    }}
+                    style={{
+                      display: "block",
+                      width: "100%",
+                      padding: "10px 16px",
+                      fontSize: 14,
+                      border: "none",
+                      textAlign: "left",
+                      background:
+                        i === selectedIndex
+                          ? "rgba(255,255,255,0.08)"
+                          : "transparent",
+                      color: i === selectedIndex ? "#fff" : "#888",
+                      fontWeight: i === selectedIndex ? 500 : 400,
+                      cursor: "pointer",
+                      borderLeft:
+                        i === selectedIndex
+                          ? "2px solid #fff"
+                          : "2px solid transparent",
+                      fontFamily: "inherit",
+                    }}
+                  >
+                    {scene.name}
+                  </button>
+                ))}
+              </div>
+            </div>
+          </>
+        )}
+
+        {showJson && (
+          <>
+            <div
+              onClick={() => setShowJson(false)}
+              style={{
+                position: "absolute",
+                inset: 0,
+                background: "rgba(0,0,0,0.5)",
+                zIndex: 10,
+              }}
+            />
+            <div
+              style={{
+                position: "absolute",
+                top: 0,
+                right: 0,
+                bottom: 0,
+                width: "85%",
+                maxWidth: 400,
+                background: "#0d0d0d",
+                borderLeft: "1px solid #1e1e1e",
+                zIndex: 11,
+                display: "flex",
+                flexDirection: "column",
+              }}
+            >
+              <div style={{ ...headerStyle, height: 36 }}>Spec JSON</div>
+              <pre
+                style={{
+                  flex: 1,
+                  margin: 0,
+                  padding: 14,
+                  overflowY: "auto",
+                  overflowX: "auto",
+                  fontSize: 11,
+                  lineHeight: 1.6,
+                  fontFamily:
+                    "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
+                  color: "#EDEDED",
+                  tabSize: 2,
+                }}
+                dangerouslySetInnerHTML={{
+                  __html: highlightJson(JSON.stringify(selected.spec, null, 2)),
+                }}
+              />
+            </div>
+          </>
+        )}
+      </div>
+    </div>
+  );
+}
+
+export default function Page() {
+  const isMobile = useIsMobile();
+  return isMobile ? <MobileLayout /> : <DesktopLayout />;
+}

+ 3 - 0
examples/react-three-fiber-gsplat/app/scenes/_helpers.ts

@@ -0,0 +1,3 @@
+import type { Spec } from "@json-render/core";
+
+export type Scene = { name: string; description: string; spec: Spec };

+ 166 - 0
examples/react-three-fiber-gsplat/app/scenes/floating-splat.ts

@@ -0,0 +1,166 @@
+import type { Scene } from "./_helpers";
+
+export const floatingSplat: Scene = {
+  name: "Floating Splat",
+  description:
+    "A gaussian splat wrapped in Float and Spin animation wrappers with sparkle particles",
+  spec: {
+    root: "scene",
+    elements: {
+      scene: {
+        type: "Group",
+        props: { position: null, rotation: null, scale: null },
+        children: [
+          "camera",
+          "env",
+          "ambient",
+          "key-light",
+          "fill-light",
+          "float-wrapper",
+          "sparkles",
+          "grid",
+          "controls",
+        ],
+      },
+      camera: {
+        type: "PerspectiveCamera",
+        props: {
+          position: [0, 2, 6],
+          rotation: null,
+          scale: null,
+          fov: 45,
+          near: null,
+          far: null,
+          makeDefault: true,
+        },
+        children: [],
+      },
+      env: {
+        type: "Environment",
+        props: {
+          preset: "studio",
+          background: false,
+          blur: 0.5,
+          intensity: 0.5,
+        },
+        children: [],
+      },
+      ambient: {
+        type: "AmbientLight",
+        props: { color: "#e0e0ff", intensity: 0.3 },
+        children: [],
+      },
+      "key-light": {
+        type: "DirectionalLight",
+        props: {
+          position: [5, 8, 3],
+          rotation: null,
+          scale: null,
+          color: "#ffffff",
+          intensity: 2,
+          castShadow: true,
+        },
+        children: [],
+      },
+      "fill-light": {
+        type: "PointLight",
+        props: {
+          position: [-3, 3, -2],
+          rotation: null,
+          scale: null,
+          color: "#aaccff",
+          intensity: 10,
+          distance: 15,
+          decay: null,
+          castShadow: null,
+        },
+        children: [],
+      },
+      "float-wrapper": {
+        type: "Float",
+        props: {
+          position: [0, 1, 0],
+          rotation: null,
+          scale: null,
+          speed: 1.5,
+          rotationIntensity: 0.2,
+          floatIntensity: 0.5,
+          enabled: true,
+        },
+        children: ["spin-wrapper"],
+      },
+      "spin-wrapper": {
+        type: "Spin",
+        props: {
+          position: null,
+          rotation: null,
+          scale: null,
+          speed: 0.2,
+          axis: "y",
+        },
+        children: ["splat"],
+      },
+      splat: {
+        type: "GaussianSplat",
+        props: {
+          src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/stump/stump-7k.splat",
+          position: [0, 0, 0],
+          rotation: null,
+          scale: [0.8, 0.8, 0.8],
+          castShadow: null,
+          receiveShadow: null,
+          alphaHash: null,
+          toneMapped: null,
+          visible: true,
+        },
+        children: [],
+      },
+      sparkles: {
+        type: "Sparkles",
+        props: {
+          position: [0, 1.5, 0],
+          rotation: null,
+          scale: [6, 4, 6],
+          count: 40,
+          speed: 0.08,
+          opacity: 0.2,
+          color: "#ffffff",
+          size: 0.4,
+          noise: 0.5,
+        },
+        children: [],
+      },
+      grid: {
+        type: "GridHelper",
+        props: {
+          position: [0, -0.5, 0],
+          rotation: null,
+          scale: null,
+          size: 20,
+          divisions: 20,
+          color: "#222222",
+          secondaryColor: "#111111",
+        },
+        children: [],
+      },
+      controls: {
+        type: "OrbitControls",
+        props: {
+          enableDamping: true,
+          dampingFactor: null,
+          enableZoom: null,
+          enablePan: null,
+          enableRotate: null,
+          minDistance: 3,
+          maxDistance: 15,
+          minPolarAngle: 0.2,
+          maxPolarAngle: 1.5,
+          autoRotate: true,
+          autoRotateSpeed: 0.4,
+          target: [0, 1, 0],
+        },
+        children: [],
+      },
+    },
+  },
+};

+ 15 - 0
examples/react-three-fiber-gsplat/app/scenes/index.ts

@@ -0,0 +1,15 @@
+import type { Scene } from "./_helpers";
+import { splatShowroom } from "./splat-showroom";
+import { splatWithPrimitives } from "./splat-with-primitives";
+import { multiSplat } from "./multi-splat";
+import { splatPostFx } from "./splat-postfx";
+import { floatingSplat } from "./floating-splat";
+export type { Scene };
+
+export const scenes: Scene[] = [
+  splatShowroom,
+  splatWithPrimitives,
+  multiSplat,
+  splatPostFx,
+  floatingSplat,
+];

+ 146 - 0
examples/react-three-fiber-gsplat/app/scenes/multi-splat.ts

@@ -0,0 +1,146 @@
+import type { Scene } from "./_helpers";
+
+export const multiSplat: Scene = {
+  name: "Multi-Splat Gallery",
+  description: "Multiple gaussian splats arranged in a gallery with spotlights",
+  spec: {
+    root: "scene",
+    elements: {
+      scene: {
+        type: "Group",
+        props: { position: null, rotation: null, scale: null },
+        children: [
+          "camera",
+          "ambient",
+          "spot-left",
+          "spot-right",
+          "splat-left",
+          "splat-right",
+          "grid",
+          "fog",
+          "controls",
+        ],
+      },
+      camera: {
+        type: "PerspectiveCamera",
+        props: {
+          position: [0, 3, 8],
+          rotation: null,
+          scale: null,
+          fov: 50,
+          near: null,
+          far: null,
+          makeDefault: true,
+        },
+        children: [],
+      },
+      ambient: {
+        type: "AmbientLight",
+        props: { color: "#e0e0ff", intensity: 0.2 },
+        children: [],
+      },
+      "spot-left": {
+        type: "SpotLight",
+        props: {
+          position: [-3, 6, 2],
+          rotation: null,
+          scale: null,
+          color: "#ffddaa",
+          intensity: 60,
+          distance: 15,
+          decay: null,
+          angle: 0.5,
+          penumbra: 0.8,
+          castShadow: true,
+        },
+        children: [],
+      },
+      "spot-right": {
+        type: "SpotLight",
+        props: {
+          position: [3, 6, 2],
+          rotation: null,
+          scale: null,
+          color: "#aaddff",
+          intensity: 60,
+          distance: 15,
+          decay: null,
+          angle: 0.5,
+          penumbra: 0.8,
+          castShadow: true,
+        },
+        children: [],
+      },
+      "splat-left": {
+        type: "GaussianSplat",
+        props: {
+          src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
+          position: [-3, 0, 0],
+          rotation: null,
+          scale: [0.8, 0.8, 0.8],
+          castShadow: null,
+          receiveShadow: null,
+          alphaHash: null,
+          toneMapped: null,
+          visible: true,
+        },
+        children: [],
+      },
+      "splat-right": {
+        type: "GaussianSplat",
+        props: {
+          src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/garden/garden-7k.splat",
+          position: [3, 0, 0],
+          rotation: null,
+          scale: [0.8, 0.8, 0.8],
+          castShadow: null,
+          receiveShadow: null,
+          alphaHash: null,
+          toneMapped: null,
+          visible: true,
+        },
+        children: [],
+      },
+      grid: {
+        type: "GridHelper",
+        props: {
+          position: [0, -0.5, 0],
+          rotation: null,
+          scale: null,
+          size: 30,
+          divisions: 30,
+          color: "#222222",
+          secondaryColor: "#111111",
+        },
+        children: [],
+      },
+      fog: {
+        type: "Fog",
+        props: {
+          color: "#0a0a0a",
+          near: 8,
+          far: 25,
+        },
+        children: [],
+      },
+      controls: {
+        type: "OrbitControls",
+        props: {
+          enableDamping: true,
+          dampingFactor: null,
+          enableZoom: null,
+          enablePan: null,
+          enableRotate: null,
+          minDistance: 3,
+          maxDistance: 18,
+          minPolarAngle: 0.3,
+          maxPolarAngle: 1.4,
+          autoRotate: true,
+          autoRotateSpeed: 0.3,
+          target: [0, 0.5, 0],
+        },
+        children: [],
+      },
+    },
+  },
+};

+ 134 - 0
examples/react-three-fiber-gsplat/app/scenes/splat-postfx.ts

@@ -0,0 +1,134 @@
+import type { Scene } from "./_helpers";
+
+export const splatPostFx: Scene = {
+  name: "Splat + Post-Processing",
+  description:
+    "A gaussian splat with bloom and vignette post-processing effects",
+  spec: {
+    root: "scene",
+    elements: {
+      scene: {
+        type: "Group",
+        props: { position: null, rotation: null, scale: null },
+        children: [
+          "camera",
+          "env",
+          "ambient",
+          "key-light",
+          "splat",
+          "grid",
+          "fx",
+          "controls",
+        ],
+      },
+      camera: {
+        type: "PerspectiveCamera",
+        props: {
+          position: [3, 2, 5],
+          rotation: null,
+          scale: null,
+          fov: 50,
+          near: null,
+          far: null,
+          makeDefault: true,
+        },
+        children: [],
+      },
+      env: {
+        type: "Environment",
+        props: {
+          preset: "night",
+          background: false,
+          blur: 0.8,
+          intensity: 0.4,
+        },
+        children: [],
+      },
+      ambient: {
+        type: "AmbientLight",
+        props: { color: "#ccccff", intensity: 0.2 },
+        children: [],
+      },
+      "key-light": {
+        type: "PointLight",
+        props: {
+          position: [3, 5, 3],
+          rotation: null,
+          scale: null,
+          color: "#ffeecc",
+          intensity: 30,
+          distance: 20,
+          decay: null,
+          castShadow: null,
+        },
+        children: [],
+      },
+      splat: {
+        type: "GaussianSplat",
+        props: {
+          src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
+          position: [0, 0, 0],
+          rotation: null,
+          scale: [1, 1, 1],
+          castShadow: null,
+          receiveShadow: null,
+          alphaHash: null,
+          toneMapped: null,
+          visible: true,
+        },
+        children: [],
+      },
+      grid: {
+        type: "GridHelper",
+        props: {
+          position: [0, -0.5, 0],
+          rotation: null,
+          scale: null,
+          size: 20,
+          divisions: 20,
+          color: "#222222",
+          secondaryColor: "#111111",
+        },
+        children: [],
+      },
+      fx: {
+        type: "EffectComposer",
+        props: { enabled: true, multisampling: 8 },
+        children: ["bloom", "vignette"],
+      },
+      bloom: {
+        type: "Bloom",
+        props: {
+          intensity: 0.8,
+          luminanceThreshold: 0.2,
+          luminanceSmoothing: 0.5,
+          mipmapBlur: true,
+        },
+        children: [],
+      },
+      vignette: {
+        type: "Vignette",
+        props: { offset: 0.25, darkness: 0.7 },
+        children: [],
+      },
+      controls: {
+        type: "OrbitControls",
+        props: {
+          enableDamping: true,
+          dampingFactor: null,
+          enableZoom: null,
+          enablePan: null,
+          enableRotate: null,
+          minDistance: 2,
+          maxDistance: 12,
+          minPolarAngle: 0.3,
+          maxPolarAngle: 1.5,
+          autoRotate: true,
+          autoRotateSpeed: 0.5,
+          target: [0, 0.5, 0],
+        },
+        children: [],
+      },
+    },
+  },
+};

+ 126 - 0
examples/react-three-fiber-gsplat/app/scenes/splat-showroom.ts

@@ -0,0 +1,126 @@
+import type { Scene } from "./_helpers";
+
+export const splatShowroom: Scene = {
+  name: "Splat Showroom",
+  description:
+    "A gaussian splat on a pedestal with studio lighting and orbit controls",
+  spec: {
+    root: "scene",
+    elements: {
+      scene: {
+        type: "Group",
+        props: { position: null, rotation: null, scale: null },
+        children: [
+          "camera",
+          "env",
+          "ambient",
+          "key-light",
+          "fill-light",
+          "splat",
+          "grid",
+          "controls",
+        ],
+      },
+      camera: {
+        type: "PerspectiveCamera",
+        props: {
+          position: [3, 2, 5],
+          rotation: null,
+          scale: null,
+          fov: 50,
+          near: null,
+          far: null,
+          makeDefault: true,
+        },
+        children: [],
+      },
+      env: {
+        type: "Environment",
+        props: {
+          preset: "studio",
+          background: false,
+          blur: 0.5,
+          intensity: 0.6,
+        },
+        children: [],
+      },
+      ambient: {
+        type: "AmbientLight",
+        props: { color: "#e8e0f0", intensity: 0.3 },
+        children: [],
+      },
+      "key-light": {
+        type: "DirectionalLight",
+        props: {
+          position: [5, 8, 3],
+          rotation: null,
+          scale: null,
+          color: "#ffffff",
+          intensity: 2,
+          castShadow: true,
+        },
+        children: [],
+      },
+      "fill-light": {
+        type: "PointLight",
+        props: {
+          position: [-3, 4, -2],
+          rotation: null,
+          scale: null,
+          color: "#aaccff",
+          intensity: 15,
+          distance: 20,
+          decay: null,
+          castShadow: null,
+        },
+        children: [],
+      },
+      splat: {
+        type: "GaussianSplat",
+        props: {
+          src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
+          position: [0, 0, 0],
+          rotation: null,
+          scale: [1, 1, 1],
+          castShadow: null,
+          receiveShadow: null,
+          alphaHash: null,
+          toneMapped: null,
+          visible: true,
+        },
+        children: [],
+      },
+      grid: {
+        type: "GridHelper",
+        props: {
+          position: [0, -0.5, 0],
+          rotation: null,
+          scale: null,
+          size: 20,
+          divisions: 20,
+          color: "#333333",
+          secondaryColor: "#1a1a1a",
+        },
+        children: [],
+      },
+      controls: {
+        type: "OrbitControls",
+        props: {
+          enableDamping: true,
+          dampingFactor: null,
+          enableZoom: null,
+          enablePan: null,
+          enableRotate: null,
+          minDistance: 2,
+          maxDistance: 15,
+          minPolarAngle: 0.2,
+          maxPolarAngle: 1.5,
+          autoRotate: true,
+          autoRotateSpeed: 0.5,
+          target: [0, 0.5, 0],
+        },
+        children: [],
+      },
+    },
+  },
+};

+ 157 - 0
examples/react-three-fiber-gsplat/app/scenes/splat-with-primitives.ts

@@ -0,0 +1,157 @@
+import type { Scene } from "./_helpers";
+
+export const splatWithPrimitives: Scene = {
+  name: "Splat + Primitives",
+  description:
+    "A gaussian splat scene composed with standard 3D primitives and lights",
+  spec: {
+    root: "scene",
+    elements: {
+      scene: {
+        type: "Group",
+        props: { position: null, rotation: null, scale: null },
+        children: [
+          "camera",
+          "ambient",
+          "sun",
+          "splat",
+          "logo",
+          "label",
+          "grid",
+          "controls",
+        ],
+      },
+      camera: {
+        type: "PerspectiveCamera",
+        props: {
+          position: [4, 3, 6],
+          rotation: null,
+          scale: null,
+          fov: 50,
+          near: null,
+          far: null,
+          makeDefault: true,
+        },
+        children: [],
+      },
+      ambient: {
+        type: "AmbientLight",
+        props: { color: "#ffffff", intensity: 0.4 },
+        children: [],
+      },
+      sun: {
+        type: "DirectionalLight",
+        props: {
+          position: [8, 10, 5],
+          rotation: null,
+          scale: null,
+          color: "#fff5e0",
+          intensity: 2.5,
+          castShadow: true,
+        },
+        children: [],
+      },
+      splat: {
+        type: "GaussianSplat",
+        props: {
+          src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/garden/garden-7k.splat",
+          position: [0, 0, 0],
+          rotation: null,
+          scale: [1, 1, 1],
+          castShadow: null,
+          receiveShadow: null,
+          alphaHash: null,
+          toneMapped: null,
+          visible: true,
+        },
+        children: [],
+      },
+      logo: {
+        type: "Cone",
+        props: {
+          position: [-0.4, -0.25, 1],
+          rotation: [-1.5708, 0, 0],
+          scale: null,
+          radius: 0.1,
+          height: 0.06,
+          radialSegments: 3,
+          castShadow: true,
+          receiveShadow: null,
+          material: {
+            color: "#ffffff",
+            metalness: 0.3,
+            roughness: 0.4,
+            emissive: null,
+            emissiveIntensity: null,
+            opacity: null,
+            transparent: null,
+            wireframe: null,
+          },
+        },
+        children: [],
+      },
+      grid: {
+        type: "GridHelper",
+        props: {
+          position: [0, -0.49, 0],
+          rotation: null,
+          scale: null,
+          size: 30,
+          divisions: 30,
+          color: "#333333",
+          secondaryColor: "#1a1a1a",
+        },
+        children: [],
+      },
+      label: {
+        type: "ExtrudedText",
+        props: {
+          position: [-0.3, -0.28, 1],
+          rotation: null,
+          scale: null,
+          text: "vercel-labs/json-render",
+          font: null,
+          size: 0.1,
+          depth: 0.02,
+          curveSegments: null,
+          bevelEnabled: false,
+          bevelThickness: null,
+          bevelSize: null,
+          bevelSegments: null,
+          centered: false,
+          castShadow: true,
+          receiveShadow: null,
+          material: {
+            color: "#ffffff",
+            metalness: 1,
+            roughness: 0.4,
+            emissive: null,
+            emissiveIntensity: null,
+            opacity: null,
+            transparent: null,
+            wireframe: null,
+          },
+        },
+        children: [],
+      },
+      controls: {
+        type: "OrbitControls",
+        props: {
+          enableDamping: true,
+          dampingFactor: null,
+          enableZoom: null,
+          enablePan: null,
+          enableRotate: null,
+          minDistance: 2,
+          maxDistance: 20,
+          minPolarAngle: 0.2,
+          maxPolarAngle: 1.5,
+          autoRotate: true,
+          autoRotateSpeed: 0.3,
+          target: [0.3, -0.4, 0.4],
+        },
+        children: [],
+      },
+    },
+  },
+};

+ 12 - 0
examples/react-three-fiber-gsplat/eslint.config.js

@@ -0,0 +1,12 @@
+import { nextJsConfig } from "@internal/eslint-config/next-js";
+
+/** @type {import("eslint").Linter.Config[]} */
+export default [
+  ...nextJsConfig,
+  {
+    rules: {
+      "react/prop-types": "off",
+      "react/no-unknown-property": "off",
+    },
+  },
+];

+ 6 - 0
examples/react-three-fiber-gsplat/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 12 - 0
examples/react-three-fiber-gsplat/next.config.ts

@@ -0,0 +1,12 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  transpilePackages: [
+    "@json-render/core",
+    "@json-render/react",
+    "@json-render/react-three-fiber",
+    "three",
+  ],
+};
+
+export default nextConfig;

+ 36 - 0
examples/react-three-fiber-gsplat/package.json

@@ -0,0 +1,36 @@
+{
+  "name": "example-react-three-fiber-gsplat",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
+    "dev": "portless r3f-gsplat-demo.json-render next dev --turbopack",
+    "dev:local": "next dev --turbopack",
+    "build": "next build",
+    "start": "next start",
+    "lint": "eslint --max-warnings 0",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "@json-render/react-three-fiber": "workspace:*",
+    "@react-three/drei": "^10.7.7",
+    "@react-three/fiber": "^9.5.0",
+    "next": "16.1.6",
+    "react": "19.2.4",
+    "react-dom": "19.2.4",
+    "three": "^0.183.2",
+    "zod": "4.3.6"
+  },
+  "devDependencies": {
+    "@internal/eslint-config": "workspace:*",
+    "@types/node": "^22.10.0",
+    "@types/react": "19.2.3",
+    "@types/react-dom": "19.2.3",
+    "@types/three": "^0.183.1",
+    "eslint": "^9.39.1",
+    "typescript": "^5.7.2"
+  }
+}

+ 13 - 0
examples/react-three-fiber-gsplat/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../packages/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "declaration": false,
+    "declarationMap": false,
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

+ 1 - 1
packages/react-three-fiber/README.md

@@ -66,7 +66,7 @@ const { registry } = defineRegistry(catalog, {
 
 **Environment:** Environment, Fog, GridHelper
 
-**Text:** Text3D
+**Text:** Text3D, ExtrudedText
 
 **Camera/Controls:** PerspectiveCamera, OrbitControls
 

+ 47 - 0
packages/react-three-fiber/src/catalog.ts

@@ -319,6 +319,32 @@ export const threeComponentDefinitions = {
     },
   },
 
+  ExtrudedText: {
+    props: z.object({
+      ...transformProps,
+      ...shadowProps,
+      material: materialSchema.nullable(),
+      text: z.string(),
+      font: z.string().nullable(),
+      size: z.number().nullable(),
+      depth: z.number().nullable(),
+      curveSegments: z.number().nullable(),
+      bevelEnabled: z.boolean().nullable(),
+      bevelThickness: z.number().nullable(),
+      bevelSize: z.number().nullable(),
+      bevelSegments: z.number().nullable(),
+      centered: z.boolean().nullable(),
+    }),
+    description:
+      "Extruded 3D text with depth. Uses a typeface JSON font for geometry-based rendering with bevel support.",
+    example: {
+      text: "Hello",
+      size: 1,
+      depth: 0.2,
+      position: [0, 2, 0],
+    },
+  },
+
   // ===========================================================================
   // Effects / Atmosphere
   // ===========================================================================
@@ -816,6 +842,27 @@ export const threeComponentDefinitions = {
       "Orbit camera controls. Allows the user to rotate, zoom, and pan around the scene.",
     example: { enableDamping: true, autoRotate: false },
   },
+
+  // ===========================================================================
+  // Gaussian Splatting
+  // ===========================================================================
+
+  GaussianSplat: {
+    props: z.object({
+      src: z.string(),
+      ...transformProps,
+      ...shadowProps,
+      alphaHash: z.boolean().nullable(),
+      toneMapped: z.boolean().nullable(),
+      visible: z.boolean().nullable(),
+    }),
+    description:
+      "Loads and renders a .splat or .ply gaussian splat file inside an R3F scene. Composable with all other 3D components (lights, models, primitives). Uses drei's Splat loader.",
+    example: {
+      src: "https://huggingface.co/datasets/dylanebert/3dgs/resolve/main/bonsai/bonsai-7k.splat",
+      position: [0, 0, 0],
+    },
+  },
 };
 
 // =============================================================================

+ 58 - 0
packages/react-three-fiber/src/components.tsx

@@ -6,7 +6,10 @@ import {
   OrbitControls as DreiOrbitControls,
   PerspectiveCamera as DreiPerspectiveCamera,
   Text as DreiText,
+  Text3D as DreiText3D,
+  Center as DreiCenter,
   Gltf,
+  Splat as DreiSplat,
   Sparkles as DreiSparkles,
   Stars as DreiStars,
   Sky as DreiSky,
@@ -350,6 +353,41 @@ export const threeComponents = {
     </DreiText>
   ),
 
+  ExtrudedText: ({ props }: BaseComponentProps<ThreeProps<"ExtrudedText">>) => {
+    const font =
+      props.font ??
+      "https://cdn.jsdelivr.net/npm/three/examples/fonts/helvetiker_regular.typeface.json";
+    const inner = (
+      <DreiText3D
+        font={font}
+        size={props.size ?? 1}
+        height={props.depth ?? 0.2}
+        curveSegments={props.curveSegments ?? 12}
+        bevelEnabled={props.bevelEnabled ?? false}
+        bevelThickness={props.bevelThickness ?? 0.02}
+        bevelSize={props.bevelSize ?? 0.02}
+        bevelSegments={props.bevelSegments ?? 3}
+        castShadow={props.castShadow ?? false}
+        receiveShadow={props.receiveShadow ?? false}
+      >
+        {props.text}
+        <MaterialComponent material={props.material} />
+      </DreiText3D>
+    );
+
+    return (
+      <Suspense fallback={null}>
+        <group
+          position={pos(props.position)}
+          rotation={rot(props.rotation)}
+          scale={scl(props.scale)}
+        >
+          {(props.centered ?? true) ? <DreiCenter>{inner}</DreiCenter> : inner}
+        </group>
+      </Suspense>
+    );
+  },
+
   // ── Effects / Atmosphere ──────────────────────────────────────────────
 
   Sparkles: ({ props }: BaseComponentProps<ThreeProps<"Sparkles">>) => (
@@ -906,4 +944,24 @@ export const threeComponents = {
       target={props.target ?? undefined}
     />
   ),
+
+  // ── Gaussian Splatting ──────────────────────────────────────────────
+
+  GaussianSplat: ({
+    props,
+  }: BaseComponentProps<ThreeProps<"GaussianSplat">>) => (
+    <Suspense fallback={null}>
+      <DreiSplat
+        src={props.src}
+        position={pos(props.position)}
+        rotation={rot(props.rotation)}
+        scale={scl(props.scale)}
+        castShadow={props.castShadow ?? false}
+        receiveShadow={props.receiveShadow ?? false}
+        alphaHash={props.alphaHash ?? undefined}
+        toneMapped={props.toneMapped ?? undefined}
+        visible={props.visible ?? true}
+      />
+    </Suspense>
+  ),
 };

+ 95 - 1
pnpm-lock.yaml

@@ -565,6 +565,40 @@ importers:
         specifier: ^5.7.2
         version: 5.9.3
 
+  examples/gsplat:
+    dependencies:
+      gsplat:
+        specifier: ^1.2.9
+        version: 1.2.9
+      next:
+        specifier: 16.1.6
+        version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      react:
+        specifier: 19.2.4
+        version: 19.2.4
+      react-dom:
+        specifier: 19.2.4
+        version: 19.2.4(react@19.2.4)
+    devDependencies:
+      '@internal/eslint-config':
+        specifier: workspace:*
+        version: link:../../packages/eslint-config
+      '@types/node':
+        specifier: ^22.10.0
+        version: 22.19.6
+      '@types/react':
+        specifier: 19.2.3
+        version: 19.2.3
+      '@types/react-dom':
+        specifier: 19.2.3
+        version: 19.2.3(@types/react@19.2.3)
+      eslint:
+        specifier: ^9.39.1
+        version: 9.39.2(jiti@2.6.1)
+      typescript:
+        specifier: ^5.7.2
+        version: 5.9.3
+
   examples/image:
     dependencies:
       '@json-render/core':
@@ -1174,6 +1208,61 @@ importers:
         specifier: ^5.7.2
         version: 5.9.3
 
+  examples/react-three-fiber-gsplat:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../../packages/core
+      '@json-render/react':
+        specifier: workspace:*
+        version: link:../../packages/react
+      '@json-render/react-three-fiber':
+        specifier: workspace:*
+        version: link:../../packages/react-three-fiber
+      '@react-three/drei':
+        specifier: ^10.7.7
+        version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.3)(@types/three@0.183.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)
+      '@react-three/fiber':
+        specifier: ^9.5.0
+        version: 9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.183.2)
+      next:
+        specifier: 16.1.6
+        version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      react:
+        specifier: 19.2.4
+        version: 19.2.4
+      react-dom:
+        specifier: 19.2.4
+        version: 19.2.4(react@19.2.4)
+      three:
+        specifier: ^0.183.2
+        version: 0.183.2
+      zod:
+        specifier: 4.3.6
+        version: 4.3.6
+    devDependencies:
+      '@internal/eslint-config':
+        specifier: workspace:*
+        version: link:../../packages/eslint-config
+      '@types/node':
+        specifier: ^22.10.0
+        version: 22.19.6
+      '@types/react':
+        specifier: 19.2.3
+        version: 19.2.3
+      '@types/react-dom':
+        specifier: 19.2.3
+        version: 19.2.3(@types/react@19.2.3)
+      '@types/three':
+        specifier: ^0.183.1
+        version: 0.183.1
+      eslint:
+        specifier: ^9.39.1
+        version: 9.39.2(jiti@2.6.1)
+      typescript:
+        specifier: ^5.7.2
+        version: 5.9.3
+
   examples/remotion:
     dependencies:
       '@ai-sdk/gateway':
@@ -9284,6 +9373,9 @@ packages:
     resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
     engines: {node: '>=6.0'}
 
+  gsplat@1.2.9:
+    resolution: {integrity: sha512-zfpaL9TEQD4oHNesL77KYeRgWvIy2+7P3jRsOA0d8So4Cv/OELRoMXo687ZEXqUMCekiXJgpFw9dz12021NFlw==}
+
   has-bigints@1.1.0:
     resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
     engines: {node: '>= 0.4'}
@@ -22830,6 +22922,8 @@ snapshots:
       section-matter: 1.0.0
       strip-bom-string: 1.0.0
 
+  gsplat@1.2.9: {}
+
   has-bigints@1.1.0: {}
 
   has-flag@3.0.0: {}
@@ -25306,7 +25400,7 @@ snapshots:
     dependencies:
       '@next/env': 16.1.6
       '@swc/helpers': 0.5.15
-      baseline-browser-mapping: 2.9.14
+      baseline-browser-mapping: 2.10.10
       caniuse-lite: 1.0.30001764
       postcss: 8.4.31
       react: 19.2.4