{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pendulum-wave",
  "title": "PendulumWave",
  "description": "A row of pendulums with stepped periods drifting into snakes, arches and chaos before snapping back in phase, bobs tinted --accent to --primary across the swing.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "files": [
    {
      "path": "components/threecn/pendulum-wave.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { useFrame } from \"@react-three/fiber\"\nimport * as THREE from \"three\"\n\nimport {\n  SceneContainer,\n  type SceneContainerProps,\n} from \"@/components/threecn/scene-container\"\nimport {\n  useShadcnTheme,\n  type ThemeMode,\n} from \"@/components/hooks/use-shadcn-theme\"\n\n/** Total width of the row of pendulums. */\nconst WIDTH = 6\n/** Height of the support bar the pendulums hang from. */\nconst TOP_Y = 1.7\n/** Seconds for the whole pattern to drift out of phase and realign. */\nconst CYCLE = 26\n/** Oscillations the slowest (longest) pendulum completes per cycle. */\nconst BASE_SWINGS = 7\nconst UP = new THREE.Vector3(0, 1, 0)\n\nfunction Pendulums({\n  count,\n  speed,\n  swing,\n  theme,\n}: {\n  count: number\n  speed: number\n  swing: number\n  theme: ThemeMode\n}) {\n  const { primaryColor, accentColor, mutedColor } = useShadcnTheme(theme)\n  const bobsRef = React.useRef<THREE.InstancedMesh>(null)\n  const stringsRef = React.useRef<THREE.InstancedMesh>(null)\n  const dummy = React.useMemo(() => new THREE.Object3D(), [])\n  const tmpColor = React.useMemo(() => new THREE.Color(), [])\n  const tmpDir = React.useMemo(() => new THREE.Vector3(), [])\n\n  // Pendulum i completes (BASE_SWINGS + i) oscillations per CYCLE, so the row\n  // drifts through snakes and chaos before snapping back in phase. Lengths\n  // follow real pendulum physics (L ∝ 1/f²), giving the descending cascade.\n  const lengths = React.useMemo(\n    () =>\n      Array.from(\n        { length: count },\n        (_, i) => 3.1 * (BASE_SWINGS / (BASE_SWINGS + i)) ** 2\n      ),\n    [count]\n  )\n\n  const spacing = count > 1 ? WIDTH / (count - 1) : 0\n  const bobRadius = Math.min(0.18, Math.max(0.09, spacing * 0.42))\n\n  useFrame(({ clock }) => {\n    const bobs = bobsRef.current\n    const strings = stringsRef.current\n    if (!bobs || !strings) return\n    const t = clock.getElapsedTime() * speed\n    const swingRad = (swing * Math.PI) / 180\n\n    for (let i = 0; i < count; i++) {\n      const f = (BASE_SWINGS + i) / CYCLE\n      // cos → every pendulum starts released from its full amplitude, so the\n      // row begins as one clean diagonal sweep.\n      const angle = swingRad * Math.cos(2 * Math.PI * f * t)\n      const x = -WIDTH / 2 + i * spacing\n      const len = lengths[i]\n\n      // Swing happens in the Z plane, perpendicular to the row.\n      tmpDir.set(0, -Math.cos(angle), Math.sin(angle))\n      dummy.position.set(x, TOP_Y, 0).addScaledVector(tmpDir, len)\n      dummy.quaternion.identity()\n      dummy.scale.setScalar(1)\n      dummy.updateMatrix()\n      bobs.setMatrixAt(i, dummy.matrix)\n      const k = swingRad > 1e-4 ? angle / swingRad / 2 + 0.5 : 0.5\n      tmpColor.copy(accentColor).lerp(primaryColor, k)\n      bobs.setColorAt(i, tmpColor)\n\n      // String: a thin rod from the pivot down to the bob.\n      dummy.position.set(x, TOP_Y, 0).addScaledVector(tmpDir, len / 2)\n      dummy.quaternion.setFromUnitVectors(UP, tmpDir)\n      dummy.scale.set(1, len, 1)\n      dummy.updateMatrix()\n      strings.setMatrixAt(i, dummy.matrix)\n    }\n\n    bobs.instanceMatrix.needsUpdate = true\n    strings.instanceMatrix.needsUpdate = true\n    if (bobs.instanceColor) bobs.instanceColor.needsUpdate = true\n  })\n\n  return (\n    <group position={[0, -0.2, 0]}>\n      <mesh position={[0, TOP_Y, 0]}>\n        <boxGeometry args={[WIDTH + 0.7, 0.09, 0.09]} />\n        <meshStandardMaterial\n          color={mutedColor}\n          roughness={0.4}\n          metalness={0.5}\n        />\n      </mesh>\n      <instancedMesh\n        key={count}\n        ref={stringsRef}\n        args={[undefined, undefined, count]}\n      >\n        <cylinderGeometry args={[0.012, 0.012, 1, 6]} />\n        <meshStandardMaterial color={mutedColor} roughness={0.6} />\n      </instancedMesh>\n      <instancedMesh\n        key={`b${count}`}\n        ref={bobsRef}\n        args={[undefined, undefined, count]}\n      >\n        <sphereGeometry args={[bobRadius, 24, 24]} />\n        <meshStandardMaterial roughness={0.25} metalness={0.45} />\n      </instancedMesh>\n    </group>\n  )\n}\n\nexport type PendulumWaveProps = {\n  /** Number of pendulums in the row. */\n  count?: number\n  /** Time speed multiplier. */\n  speed?: number\n  /** Release amplitude in degrees. */\n  swing?: number\n  className?: string\n  theme?: ThemeMode\n  environment?: SceneContainerProps[\"environment\"]\n}\n\n/**\n * The classic pendulum-wave machine: a row of pendulums whose periods are\n * stepped so the line drifts into traveling snakes, mirrored arches and\n * apparent chaos before snapping back into one clean sweep. Bobs tint from\n * `--accent` to `--primary` across the swing.\n */\nexport function PendulumWave({\n  count = 15,\n  speed = 1,\n  swing = 26,\n  className,\n  theme = \"auto\",\n  environment = \"studio\",\n}: PendulumWaveProps) {\n  return (\n    <SceneContainer\n      className={className}\n      theme={theme}\n      environment={environment}\n      camera={[3.2, 2.2, 6.8]}\n      fov={38}\n    >\n      <Pendulums count={count} speed={speed} swing={swing} theme={theme} />\n    </SceneContainer>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "components/threecn/scene-container.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Canvas } from \"@react-three/fiber\"\nimport { OrbitControls } from \"@react-three/drei\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  useShadcnTheme,\n  type ThemeMode,\n} from \"@/components/hooks/use-shadcn-theme\"\n\nexport type EnvironmentPreset = \"studio\" | \"city\" | \"dawn\" | \"night\"\n\ntype LightRig = {\n  ambient: { color: string; intensity: number }\n  key: { color: string; intensity: number; position: [number, number, number] }\n  fill: { color: string; intensity: number; position: [number, number, number] }\n  rim: { color: string; intensity: number; position: [number, number, number] }\n}\n\n/**\n * Offline lighting rigs. We avoid drei's <Environment preset> because it\n * fetches HDRIs from a CDN — scenes must work with zero external assets.\n */\nconst RIGS: Record<EnvironmentPreset, LightRig> = {\n  studio: {\n    ambient: { color: \"#ffffff\", intensity: 0.6 },\n    key: { color: \"#ffffff\", intensity: 2.2, position: [5, 6, 5] },\n    fill: { color: \"#dfe3ff\", intensity: 0.8, position: [-6, 2, 4] },\n    rim: { color: \"#ffffff\", intensity: 1.4, position: [0, 4, -6] },\n  },\n  city: {\n    ambient: { color: \"#c7d2fe\", intensity: 0.7 },\n    key: { color: \"#e0e7ff\", intensity: 1.8, position: [4, 7, 4] },\n    fill: { color: \"#a5b4fc\", intensity: 0.9, position: [-5, 1, 5] },\n    rim: { color: \"#818cf8\", intensity: 1.2, position: [-2, 3, -6] },\n  },\n  dawn: {\n    ambient: { color: \"#fde2c8\", intensity: 0.5 },\n    key: { color: \"#ffb27a\", intensity: 2.0, position: [6, 4, 4] },\n    fill: { color: \"#9fb4ff\", intensity: 0.7, position: [-5, 2, 4] },\n    rim: { color: \"#ffd9a8\", intensity: 1.0, position: [0, 5, -5] },\n  },\n  night: {\n    ambient: { color: \"#1e1b4b\", intensity: 0.4 },\n    key: { color: \"#a78bfa\", intensity: 1.6, position: [4, 5, 4] },\n    fill: { color: \"#4338ca\", intensity: 0.6, position: [-5, 1, 4] },\n    rim: { color: \"#7c3aed\", intensity: 1.6, position: [-2, 3, -6] },\n  },\n}\n\nfunction Rig({\n  preset,\n  fog,\n  theme,\n}: {\n  preset: EnvironmentPreset\n  fog: boolean\n  theme: ThemeMode\n}) {\n  const { bgColor } = useShadcnTheme(theme)\n  const rig = RIGS[preset]\n\n  return (\n    <>\n      {fog ? <fog attach=\"fog\" args={[bgColor.getHex(), 8, 24]} /> : null}\n      <ambientLight color={rig.ambient.color} intensity={rig.ambient.intensity} />\n      <directionalLight\n        color={rig.key.color}\n        intensity={rig.key.intensity}\n        position={rig.key.position}\n      />\n      <directionalLight\n        color={rig.fill.color}\n        intensity={rig.fill.intensity}\n        position={rig.fill.position}\n      />\n      <pointLight\n        color={rig.rim.color}\n        intensity={rig.rim.intensity * 12}\n        position={rig.rim.position}\n        distance={30}\n      />\n    </>\n  )\n}\n\n/**\n * Track whether an element is near the viewport. WebGL contexts are a scarce\n * resource (browsers cap them at ~8-16), so scenes only hold one while they are\n * on (or close to) screen. The generous `rootMargin` pre-mounts a scene just\n * before it scrolls into view, avoiding a blank first frame.\n */\nfunction useInView(\n  ref: React.RefObject<HTMLElement | null>,\n  rootMargin: string\n): boolean {\n  const [inView, setInView] = React.useState(false)\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    if (typeof IntersectionObserver === \"undefined\") {\n      // No observer available: render eagerly rather than never mounting.\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setInView(true)\n      return\n    }\n    const observer = new IntersectionObserver(\n      ([entry]) => setInView(entry.isIntersecting),\n      { rootMargin }\n    )\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [ref, rootMargin])\n\n  return inView\n}\n\nexport type SceneContainerProps = {\n  className?: string\n  theme?: ThemeMode\n  environment?: EnvironmentPreset\n  fog?: boolean\n  /** Camera position, defaults to a gentle 3/4 view. */\n  camera?: [number, number, number]\n  /** Vertical field of view. */\n  fov?: number\n  children?: React.ReactNode\n  /** Rendered as an HTML overlay above the canvas (not inside the 3D scene). */\n  overlay?: React.ReactNode\n  /**\n   * Drag to orbit the camera (rotate only — zoom and pan disabled). Defaults to\n   * true. Set false if the scene provides its own controls.\n   */\n  orbit?: boolean\n  /**\n   * Mount the WebGL canvas only while it is near the viewport, and tear it down\n   * once it scrolls away. Keeps a grid of many scenes from exhausting the\n   * browser's WebGL context limit and stops `useFrame` from running offscreen.\n   * Defaults to true. Set false for a scene that must always render (e.g. a\n   * persistent hero background).\n   */\n  lazy?: boolean\n}\n\n/**\n * The canvas wrapper every threecn scene builds on. Sets up a transparent\n * canvas (so your CSS background shows through), responsive DPR, a themed\n * lighting rig and optional fog.\n */\nexport function SceneContainer({\n  className,\n  theme = \"auto\",\n  environment = \"studio\",\n  fog = false,\n  camera = [0, 0, 6],\n  fov = 45,\n  children,\n  overlay,\n  orbit = true,\n  lazy = true,\n}: SceneContainerProps) {\n  const wrapRef = React.useRef<HTMLDivElement>(null)\n  const inView = useInView(wrapRef, \"300px\")\n  const active = lazy ? inView : true\n\n  return (\n    <div ref={wrapRef} className={cn(\"relative h-full w-full\", className)}>\n      {active ? (\n        <Canvas\n          dpr={[1, 2]}\n          gl={{ antialias: true, alpha: true, powerPreference: \"high-performance\" }}\n          camera={{ position: camera, fov, near: 0.1, far: 100 }}\n        >\n          <Rig preset={environment} fog={fog} theme={theme} />\n          <React.Suspense fallback={null}>{children}</React.Suspense>\n          {orbit ? (\n            <OrbitControls\n              makeDefault\n              enablePan={false}\n              enableZoom={false}\n              enableDamping\n              dampingFactor={0.1}\n            />\n          ) : null}\n        </Canvas>\n      ) : null}\n      {overlay ? <div className=\"pointer-events-none absolute inset-0\">{overlay}</div> : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "components/hooks/use-shadcn-theme.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Color } from \"three\"\n\n/**\n * The set of theme-derived colors exposed to R3F scenes.\n * Every value is a live `THREE.Color`, ready to assign to a material.\n */\nexport type ShadcnThemeColors = {\n  primaryColor: Color\n  primaryForegroundColor: Color\n  bgColor: Color\n  foregroundColor: Color\n  borderColor: Color\n  mutedColor: Color\n  accentColor: Color\n  isDark: boolean\n}\n\n/** CSS custom properties read from `:root` / `.dark`. */\nconst CSS_VARS = {\n  primaryColor: \"--primary\",\n  primaryForegroundColor: \"--primary-foreground\",\n  bgColor: \"--background\",\n  foregroundColor: \"--foreground\",\n  borderColor: \"--border\",\n  mutedColor: \"--muted-foreground\",\n  accentColor: \"--accent\",\n} as const\n\n/**\n * Neutral SSR defaults (a dark-ish indigo palette) used before the DOM is\n * available, so the first paint is never a flash of pure black.\n */\nfunction createDefaults(): ShadcnThemeColors {\n  return {\n    primaryColor: new Color().setHSL(263 / 360, 0.7, 0.6),\n    primaryForegroundColor: new Color().setHSL(0, 0, 0.98),\n    bgColor: new Color().setHSL(240 / 360, 0.1, 0.039),\n    foregroundColor: new Color().setHSL(0, 0, 0.98),\n    borderColor: new Color().setHSL(240 / 360, 0.037, 0.159),\n    mutedColor: new Color().setHSL(240 / 360, 0.05, 0.649),\n    accentColor: new Color().setHSL(240 / 360, 0.037, 0.159),\n    isDark: true,\n  }\n}\n\n/** Matches a bare shadcn HSL triplet, e.g. \"263 70% 50%\" (optional \"/ alpha\"). */\nconst HSL_TRIPLET = /^-?[\\d.]+\\s+-?[\\d.]+%\\s+-?[\\d.]+%(\\s*\\/\\s*[\\d.]+%?)?$/\n\n/**\n * A lazily-created 1x1 canvas 2D context used to normalise arbitrary CSS\n * colors. The browser's own color parser resolves ANY format the CSS spec\n * supports — including `oklch()` (Tailwind v4 / recent shadcn), `color()`,\n * `hsl()`, `rgb()`, hex and named colors — and serialises the result back as\n * `#rrggbb` / `rgba(...)`, which `THREE.Color.setStyle` understands.\n */\nlet probeCtx: CanvasRenderingContext2D | null | undefined\nfunction getProbeCtx(): CanvasRenderingContext2D | null {\n  if (probeCtx !== undefined) return probeCtx\n  if (typeof document === \"undefined\") return (probeCtx = null)\n  probeCtx = document.createElement(\"canvas\").getContext(\"2d\")\n  return probeCtx\n}\n\n/**\n * Normalise any CSS color string to a form THREE can parse (or `null` if the\n * browser rejects it). Assigning an invalid color to `fillStyle` is a no-op, so\n * we probe with two different sentinels: if the result differs, the input was\n * valid and both reflect its normalised value; if it \"sticks\" to a sentinel,\n * the input was invalid.\n */\nfunction normalizeColor(input: string): string | null {\n  const ctx = getProbeCtx()\n  if (!ctx) return null\n  ctx.fillStyle = \"#000\"\n  ctx.fillStyle = input\n  const a = ctx.fillStyle\n  ctx.fillStyle = \"#fff\"\n  ctx.fillStyle = input\n  const b = ctx.fillStyle\n  return a === b ? a : null\n}\n\n/**\n * Parse a raw CSS color (as read from a custom property) into a `THREE.Color`.\n *\n * shadcn ships colors as bare HSL triplets (\"263 70% 50%\"), which we wrap in\n * `hsl(...)`. Everything else — `oklch()`, `rgb()`, `#hex`, `hsl()`, named — is\n * handed to the browser's parser via a canvas probe, so modern oklch-based\n * themes resolve correctly instead of silently falling back to the default.\n */\nfunction parseColor(raw: string, target: Color): Color {\n  const value = raw.trim()\n  if (!value) return target\n\n  const candidate = HSL_TRIPLET.test(value) ? `hsl(${value})` : value\n\n  // Preferred path: let the browser resolve any CSS color format.\n  const normalized = normalizeColor(candidate)\n  if (normalized) {\n    try {\n      return target.setStyle(normalized)\n    } catch {\n      /* fall through to the manual paths below */\n    }\n  }\n\n  // Fallbacks for environments without a canvas (e.g. exotic SSR shims).\n  const triplet = value.match(/^(-?[\\d.]+)\\s+(-?[\\d.]+)%\\s+(-?[\\d.]+)%/)\n  if (triplet) {\n    const h = parseFloat(triplet[1]) / 360\n    const s = parseFloat(triplet[2]) / 100\n    const l = parseFloat(triplet[3]) / 100\n    return target.setHSL(h, s, l)\n  }\n  try {\n    return target.setStyle(value)\n  } catch {\n    return target\n  }\n}\n\nfunction readColors(previous?: ShadcnThemeColors): ShadcnThemeColors {\n  if (typeof window === \"undefined\" || typeof document === \"undefined\") {\n    return previous ?? createDefaults()\n  }\n\n  const root = document.documentElement\n  const styles = getComputedStyle(root)\n  const base = previous ?? createDefaults()\n\n  const next: ShadcnThemeColors = {\n    ...base,\n    primaryColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.primaryColor),\n      base.primaryColor.clone()\n    ),\n    primaryForegroundColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.primaryForegroundColor),\n      base.primaryForegroundColor.clone()\n    ),\n    bgColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.bgColor),\n      base.bgColor.clone()\n    ),\n    foregroundColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.foregroundColor),\n      base.foregroundColor.clone()\n    ),\n    borderColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.borderColor),\n      base.borderColor.clone()\n    ),\n    mutedColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.mutedColor),\n      base.mutedColor.clone()\n    ),\n    accentColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.accentColor),\n      base.accentColor.clone()\n    ),\n    isDark: root.classList.contains(\"dark\"),\n  }\n\n  return next\n}\n\nexport type ThemeMode = \"auto\" | \"light\" | \"dark\"\n\n/**\n * Bridges your shadcn/ui CSS variables into Three.js.\n *\n * - Reads `--primary`, `--background`, `--border`, ... on mount.\n * - Watches `<html class>` with a MutationObserver and re-reads on dark-mode\n *   toggles (works with next-themes, which flips the `.dark` class).\n * - SSR-safe: returns neutral defaults until the DOM is ready.\n *\n * @param mode `\"auto\"` (default) tracks the document theme. `\"light\"` / `\"dark\"`\n *   force a palette by temporarily toggling a detached probe element.\n */\nexport function useShadcnTheme(mode: ThemeMode = \"auto\"): ShadcnThemeColors {\n  const [colors, setColors] = React.useState<ShadcnThemeColors>(() =>\n    createDefaults()\n  )\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\") return\n\n    // These reads pull the live values out of an external system (the DOM's\n    // computed CSS variables), which is a legitimate effect → state sync.\n    if (mode !== \"auto\") {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setColors((prev) => readForcedMode(mode, prev))\n      return\n    }\n\n    // Initial read once mounted (handles hydration + system theme).\n    setColors((prev) => readColors(prev))\n\n    const root = document.documentElement\n    const observer = new MutationObserver(() => {\n      setColors((prev) => readColors(prev))\n    })\n    observer.observe(root, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    })\n\n    return () => observer.disconnect()\n  }, [mode])\n\n  return colors\n}\n\n/**\n * Read the palette as it would appear under a forced light/dark mode.\n * We synchronously toggle the `.dark` class on `<html>`, read the computed\n * variables, then restore the original class in the same synchronous block.\n * getComputedStyle forces a style flush, so the values are accurate and the\n * browser never paints the intermediate state (no flicker).\n */\nfunction readForcedMode(\n  mode: \"light\" | \"dark\",\n  previous: ShadcnThemeColors\n): ShadcnThemeColors {\n  if (typeof document === \"undefined\") return previous\n\n  const root = document.documentElement\n  const wasDark = root.classList.contains(\"dark\")\n  const wantDark = mode === \"dark\"\n\n  if (wantDark !== wasDark) root.classList.toggle(\"dark\", wantDark)\n  const result = readColors(previous)\n  if (wantDark !== wasDark) root.classList.toggle(\"dark\", wasDark)\n\n  return result\n}\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:ui"
}