# Geometric Orb

> 3D animated spherical orb with flowing latitude lines and depth-based opacity

A shadcn/ui block from the [@uicapsule](https://uicapsule.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/geometric-orb).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/geometric-orb
- Screenshot: https://shadcn.exchange/shot/uicapsule/geometric-orb/light.png
- Registry item: https://uicapsule.com/r/geometric-orb.json
- Categories: effects, geometric
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/uicapsule/geometric-orb.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@react-three/drei`, `@react-three/fiber`, `three`

## Files

### `/geometric-orb.tsx`

````tsx
"use client";

import { useRef, useMemo, useEffect } from "react";
import { Canvas, useFrame, useThree, extend } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import * as THREE from "three";
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";

const OrbLine = extend(Line2);

/**
 * Configuration options for the geometric orb.
 * All fields are optional and fall back to sensible defaults.
 */
export interface GeometricOrbConfig {
  /** Number of latitude lines rendered on the sphere. @default 20 */
  numLines?: number;
  /** Radius of the sphere in world units. @default 1.5 */
  radius?: number;
  /** Duration in seconds for a full pole-to-pole animation cycle. @default 20 */
  speed?: number;
  /** Thickness of each line in pixels. @default 2 */
  lineWidth?: number;
  /** CSS color string for the lines. @default "#eeeeee" */
  color?: string;
  /** CSS color string for the canvas background. @default "#0a0a0a" */
  background?: string;
  /** Intensity of the squiggle displacement on each line. @default 0.04 */
  squiggleAmount?: number;
  /** Wave frequency of the squiggle effect. Higher = more waves. @default 4 */
  squiggleFrequency?: number;
  /** Animation speed of the squiggle oscillation. @default 2 */
  squiggleSpeed?: number;
  /** Number of points around the full circle. Higher = smoother curves + finer depth-fade. @default 96 */
  pointsPerLine?: number;
  /** Whether scroll-to-zoom is enabled. @default true */
  enableZoom?: boolean;
  /** Whether click-and-drag panning is enabled. @default false */
  enablePan?: boolean;
  /** Minimum camera distance (closest zoom). @default 2 */
  minDistance?: number;
  /** Maximum camera distance (farthest zoom). @default 20 */
  maxDistance?: number;
}

const defaults: Required<GeometricOrbConfig> = {
  background: "#0a0a0a",
  color: "#eeeeee",
  enablePan: false,
  enableZoom: true,
  lineWidth: 2,
  maxDistance: 20,
  minDistance: 2,
  numLines: 20,
  pointsPerLine: 96,
  radius: 1.5,
  speed: 20,
  squiggleAmount: 0.04,
  squiggleFrequency: 4,
  squiggleSpeed: 2,
};

/**
 * All latitude lines rendered with a single useFrame callback.
 * Each line is one Line2 with per-vertex colors encoding depth-based opacity,
 * reducing draw calls from numLines×segmentGroups to just numLines.
 */
const LatitudeLines = ({ config }: { config: Required<GeometricOrbConfig> }) => {
  const camDirRef = useRef(new THREE.Vector3());
  const { size } = useThree();

  const baseColor = useMemo(() => new THREE.Color(config.color), [config.color]);

  const lineConstants = useMemo(
    () =>
      Array.from({ length: config.numLines }, (_, i) => ({
        cosR: Math.cos((i / config.numLines) * Math.PI),
        longitudeRotation: (i / config.numLines) * Math.PI,
        sinR: Math.sin((i / config.numLines) * Math.PI),
        timeOffset: (i / config.numLines) * config.speed,
      })),
    [config.numLines, config.speed],
  );

  const materials = useMemo(
    () =>
      Array.from(
        { length: config.numLines },
        () =>
          new LineMaterial({
            color: baseColor.getHex(),
            linewidth: config.lineWidth,
            opacity: 1,
            transparent: true,
            vertexColors: true,
          }),
      ),
    [baseColor, config.numLines, config.lineWidth],
  );

  const geometries = useMemo(
    () => Array.from({ length: config.numLines }, () => new LineGeometry()),
    [config.numLines],
  );

  useEffect(
    () => () => {
      for (const material of materials) {
        material.dispose();
      }
    },
    [materials],
  );
  useEffect(
    () => () => {
      for (const geometry of geometries) {
        geometry.dispose();
      }
    },
    [geometries],
  );

  useEffect(() => {
    for (const mat of materials) {
      mat.resolution.set(size.width, size.height);
    }
  }, [materials, size.width, size.height]);

  // Reuse buffers across frames; the extra vertex closes the loop.
  const vertexCount = config.pointsPerLine + 1;
  const buffersRef = useRef<{
    vertexCount: number;
    positions: Float32Array;
    colors: Float32Array;
  } | null>(null);

  useFrame((state) => {
    if (buffersRef.current?.vertexCount !== vertexCount) {
      buffersRef.current = {
        colors: new Float32Array(vertexCount * 3),
        positions: new Float32Array(vertexCount * 3),
        vertexCount,
      };
    }
    const { positions: positionBuffer, colors: colorBuffer } = buffersRef.current;
    const time = state.clock.elapsedTime;
    const camDir = camDirRef.current.copy(state.camera.position).normalize();
    const { r } = baseColor;
    const { g } = baseColor;
    const { b } = baseColor;

    for (let lineIdx = 0; lineIdx < config.numLines; lineIdx += 1) {
      const constants = lineConstants[lineIdx];
      const geometry = geometries[lineIdx];
      if (!constants || !geometry) {
        continue;
      }
      const { timeOffset, cosR, sinR } = constants;
      const progress = ((time + timeOffset) % config.speed) / config.speed;
      const latitude = progress * Math.PI;
      const circleRadius = Math.sin(latitude) * config.radius;
      const yPosition = Math.cos(latitude) * config.radius;

      for (let i = 0; i < config.pointsPerLine; i += 1) {
        const angle = (i / config.pointsPerLine) * Math.PI * 2;
        const squiggle =
          Math.sin(angle * config.squiggleFrequency + time * config.squiggleSpeed + lineIdx * 0.5) *
          config.squiggleAmount;
        const radiusSquiggle =
          Math.cos(angle * config.squiggleFrequency * 1.3 + time * config.squiggleSpeed * 0.8) *
          config.squiggleAmount *
          0.5;
        const displacedRadius = circleRadius + (squiggle + radiusSquiggle) * circleRadius;
        const ySquiggle =
          Math.sin(angle * config.squiggleFrequency * 0.7 + time * config.squiggleSpeed * 1.2) *
          config.squiggleAmount *
          0.4;

        const x = Math.cos(angle) * displacedRadius;
        const y = yPosition + ySquiggle * circleRadius;
        const z = Math.sin(angle) * displacedRadius;

        const offset = i * 3;
        positionBuffer[offset] = x;
        positionBuffer[offset + 1] = y;
        positionBuffer[offset + 2] = z;

        // Smooth depth-fade via vertex color brightness
        const worldX = x * cosR + z * sinR;
        const worldZ = -x * sinR + z * cosR;
        const dot = worldX * camDir.x + y * camDir.y + worldZ * camDir.z;
        const depthFactor = (dot / config.radius + 1) / 2;
        const opacity = depthFactor * 0.85 + 0.15;

        colorBuffer[offset] = r * opacity;
        colorBuffer[offset + 1] = g * opacity;
        colorBuffer[offset + 2] = b * opacity;
      }

      // Close the loop: copy first vertex exactly to avoid floating-point gaps
      const last = config.pointsPerLine * 3;
      positionBuffer.copyWithin(last, 0, 3);
      colorBuffer.copyWithin(last, 0, 3);

      geometry.setPositions(positionBuffer);
      geometry.setColors(colorBuffer);
    }
  });

  return (
    <>
      {Array.from({ length: config.numLines }, (_, lineIdx) => {
        const geometry = geometries[lineIdx];
        const material = materials[lineIdx];
        const constants = lineConstants[lineIdx];
        if (!geometry || !material || !constants) {
          return null;
        }
        return (
          // Longitude rotation is constant per line, so it is declared once here
          // rather than re-written from the frame loop.
          <group key={lineIdx} rotation-y={constants.longitudeRotation}>
            <OrbLine>
              <primitive object={geometry} attach="geometry" />
              <primitive object={material} attach="material" />
            </OrbLine>
          </group>
        );
      })}
    </>
  );
};

/**
 * 3D animated orb with flowing latitude lines and depth-based opacity.
 *
 * Lines travel pole-to-pole across a sphere surface with subtle squiggle
 * displacement. Per-vertex colors encode camera-facing depth for a
 * volumetric wireframe look.
 *
 * @example
 * ```tsx
 * <GeometricOrb />
 * <GeometricOrb config={{ color: "#4af", numLines: 30, speed: 10 }} />
 * ```
 */
export const GeometricOrb = ({
  config: configOverrides,
  className = "",
}: {
  config?: GeometricOrbConfig;
  className?: string;
}) => {
  // Derived during render: LatitudeLines only memoizes on individual config
  // fields, so a fresh object identity each render costs nothing.
  const config = { ...defaults, ...configOverrides };

  return (
    <div className={`w-full h-full ${className}`} style={{ background: config.background }}>
      <Canvas camera={{ fov: 45, position: [0, 0, 8] }} gl={{ alpha: false, antialias: true }}>
        <color attach="background" args={[config.background]} />
        <LatitudeLines config={config} />
        <OrbitControls
          enablePan={config.enablePan}
          enableZoom={config.enableZoom}
          minDistance={config.minDistance}
          maxDistance={config.maxDistance}
        />
      </Canvas>
    </div>
  );
};
````

### `/preview.tsx`

```tsx
"use client";

import { GeometricOrb } from "./geometric-orb";

const Preview = () => (
  <div className="h-screen w-full">
    <GeometricOrb />
  </div>
);

export default Preview;
```
