shadimage
threecn
uicrystalsource: ok · refreshed

Crystal

A faceted glass gem with real refraction and a glowing core, tinted with --primary.

npx shadcn add "https://threecn.dev/r/crystal.json"
free

Visual style

glass-blur

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · heavythree
webglthree@react-three/fiber@react-three/drei

Files3 · source cached 10/8/2026, 2:36:12 AM

components/threecn/crystal.tsx
"use client"

import * as React from "react"
import { useFrame } from "@react-three/fiber"
import { Float } from "@react-three/drei"
import * as THREE from "three"

import {
  SceneContainer,
  type SceneContainerProps,
} from "@/components/threecn/scene-container"
import {
  useShadcnTheme,
  type ThemeMode,
} from "@/components/hooks/use-shadcn-theme"

function Gem({
  speed,
  roughness,
  ior,
  theme,
}: {
  speed: number
  roughness: number
  ior: number
  theme: ThemeMode
}) {
  const groupRef = React.useRef<THREE.Group>(null)
  const { primaryColor, accentColor } = useShadcnTheme(theme)

  useFrame((_, delta) => {
    if (groupRef.current) groupRef.current.rotation.y += delta * 0.4 * speed
  })

  return (
    <Float speed={2} rotationIntensity={0.5} floatIntensity={0.8}>
      <group ref={groupRef}>
        {/* refractive faceted gem */}
        <mesh>
          <octahedronGeometry args={[1.6, 0]} />
          <meshPhysicalMaterial
            color={primaryColor}
            transmission={1}
            thickness={1.8}
            ior={ior}
            roughness={roughness}
            metalness={0}
            clearcoat={1}
            clearcoatRoughness={0.1}
            attenuationColor={primaryColor}
            attenuationDistance={2.5}
            transparent
          />
        </mesh>
        {/* glowing inner core */}
        <mesh scale={0.42}>
          <octahedronGeometry args={[1.6, 0]} />
          <meshStandardMaterial
            color={accentColor}
            emissive={primaryColor}
            emissiveIntensity={1.4}
            toneMapped={false}
          />
        </mesh>
        {/* crisp edge highlight */}
        <lineSegments>
          <edgesGeometry args={[new THREE.OctahedronGeometry(1.62, 0)]} />
          <lineBasicMaterial color={primaryColor} transparent opacity={0.5} />
        </lineSegments>
      </group>
    </Float>
  )
}

export type CrystalProps = {
  /** Rotation speed multiplier. */
  speed?: number
  /** Surface roughness of the glass (0 = mirror-clear). */
  roughness?: number
  /** Index of refraction (how much light bends). */
  ior?: number
  className?: string
  theme?: ThemeMode
  environment?: SceneContainerProps["environment"]
}

/**
 * A faceted glass crystal with real refraction (physical transmission) and a
 * glowing inner core. Tinted with `--primary`. Self-contained and theme-aware.
 */
export function Crystal({
  speed = 1,
  roughness = 0.05,
  ior = 1.7,
  className,
  theme = "auto",
  environment = "studio",
}: CrystalProps) {
  return (
    <SceneContainer
      className={className}
      theme={theme}
      environment={environment}
      camera={[0, 0, 6]}
      fov={42}
    >
      <Gem speed={speed} roughness={roughness} ior={ior} theme={theme} />
    </SceneContainer>
  )
}
components/threecn/scene-container.tsx
"use client"

import * as React from "react"
import { Canvas } from "@react-three/fiber"
import { OrbitControls } from "@react-three/drei"

import { cn } from "@/lib/utils"
import {
  useShadcnTheme,
  type ThemeMode,
} from "@/components/hooks/use-shadcn-theme"

export type EnvironmentPreset = "studio" | "city" | "dawn" | "night"

type LightRig = {
  ambient: { color: string; intensity: number }
  key: { color: string; intensity: number; position: [number, number, number] }
  fill: { color: string; intensity: number; position: [number, number, number] }
  rim: { color: string; intensity: number; position: [number, number, number] }
}

/**
 * Offline lighting rigs. We avoid drei's <Environment preset> because it
 * fetches HDRIs from a CDN — scenes must work with zero external assets.
 */
const RIGS: Record<EnvironmentPreset, LightRig> = {
  studio: {
    ambient: { color: "#ffffff", intensity: 0.6 },
    key: { color: "#ffffff", intensity: 2.2, position: [5, 6, 5] },
    fill: { color: "#dfe3ff", intensity: 0.8, position: [-6, 2, 4] },
    rim: { color: "#ffffff", intensity: 1.4, position: [0, 4, -6] },
  },
  city: {
    ambient: { color: "#c7d2fe", intensity: 0.7 },
    key: { color: "#e0e7ff", intensity: 1.8, position: [4, 7, 4] },
    fill: { color: "#a5b4fc", intensity: 0.9, position: [-5, 1, 5] },
    rim: { color: "#818cf8", intensity: 1.2, position: [-2, 3, -6] },
  },
  dawn: {
    ambient: { color: "#fde2c8", intensity: 0.5 },
    key: { color: "#ffb27a", intensity: 2.0, position: [6, 4, 4] },
    fill: { color: "#9fb4ff", intensity: 0.7, position: [-5, 2, 4] },
    rim: { color: "#ffd9a8", intensity: 1.0, position: [0, 5, -5] },
  },
  night: {
    ambient: { color: "#1e1b4b", intensity: 0.4 },
    key: { color: "#a78bfa", intensity: 1.6, position: [4, 5, 4] },
    fill: { color: "#4338ca", intensity: 0.6, position: [-5, 1, 4] },
    rim: { color: "#7c3aed", intensity: 1.6, position: [-2, 3, -6] },
  },
}

function Rig({
  preset,
  fog,
  theme,
}: {
  preset: EnvironmentPreset
  fog: boolean
  theme: ThemeMode
}) {
  const { bgColor } = useShadcnTheme(theme)
  const rig = RIGS[preset]

  return (
    <>
      {fog ? <fog attach="fog" args={[bgColor.getHex(), 8, 24]} /> : null}
      <ambientLight color={rig.ambient.color} intensity={rig.ambient.intensity} />
      <directionalLight
        color={rig.key.color}
        intensity={rig.key.intensity}
        position={rig.key.position}
      />
      <directionalLight
        color={rig.fill.color}
        intensity={rig.fill.intensity}
        position={rig.fill.position}
      />
      <pointLight
        color={rig.rim.color}
        intensity={rig.rim.intensity * 12}
        position={rig.rim.position}
        distance={30}
      />
    </>
  )
}

/**
 * Track whether an element is near the viewport. WebGL contexts are a scarce
 * resource (browsers cap them at ~8-16), so scenes only hold one while they are
 * on (or close to) screen. The generous `rootMargin` pre-mounts a scene just
 * before it scrolls into view, avoiding a blank first frame.
 */
function useInView(
  ref: React.RefObject<HTMLElement | null>,
  rootMargin: string
): boolean {
  const [inView, setInView] = React.useState(false)

  React.useEffect(() => {
    const el = ref.current
    if (!el) return
    if (typeof IntersectionObserver === "undefined") {
      // No observer available: render eagerly rather than never mounting.
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setInView(true)
      return
    }
    const observer = new IntersectionObserver(
      ([entry]) => setInView(entry.isIntersecting),
      { rootMargin }
    )
    observer.observe(el)
    return () => observer.disconnect()
  }, [ref, rootMargin])

  return inView
}

export type SceneContainerProps = {
  className?: string
  theme?: ThemeMode
  environment?: EnvironmentPreset
  fog?: boolean
  /** Camera position, defaults to a gentle 3/4 view. */
  camera?: [number, number, number]
  /** Vertical field of view. */
  fov?: number
  children?: React.ReactNode
  /** Rendered as an HTML overlay above the canvas (not inside the 3D scene). */
  overlay?: React.ReactNode
  /**
   * Drag to orbit the camera (rotate only — zoom and pan disabled). Defaults to
   * true. Set false if the scene provides its own controls.
   */
  orbit?: boolean
  /**
   * Mount the WebGL canvas only while it is near the viewport, and tear it down
   * once it scrolls away. Keeps a grid of many scenes from exhausting the
   * browser's WebGL context limit and stops `useFrame` from running offscreen.
   * Defaults to true. Set false for a scene that must always render (e.g. a
   * persistent hero background).
   */
  lazy?: boolean
}

/**
 * The canvas wrapper every threecn scene builds on. Sets up a transparent
 * canvas (so your CSS background shows through), responsive DPR, a themed
 * lighting rig and optional fog.
 */
export function SceneContainer({
  className,
  theme = "auto",
  environment = "studio",
  fog = false,
  camera = [0, 0, 6],
  fov = 45,
  children,
  overlay,
  orbit = true,
  lazy = true,
}: SceneContainerProps) {
  const wrapRef = React.useRef<HTMLDivElement>(null)
  const inView = useInView(wrapRef, "300px")
  const active = lazy ? inView : true

  return (
    <div ref={wrapRef} className={cn("relative h-full w-full", className)}>
      {active ? (
        <Canvas
          dpr={[1, 2]}
          gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
          camera={{ position: camera, fov, near: 0.1, far: 100 }}
        >
          <Rig preset={environment} fog={fog} theme={theme} />
          <React.Suspense fallback={null}>{children}</React.Suspense>
          {orbit ? (
            <OrbitControls
              makeDefault
              enablePan={false}
              enableZoom={false}
              enableDamping
              dampingFactor={0.1}
            />
          ) : null}
        </Canvas>
      ) : null}
      {overlay ? <div className="pointer-events-none absolute inset-0">{overlay}</div> : null}
    </div>
  )
}
components/hooks/use-shadcn-theme.ts
"use client"

import * as React from "react"
import { Color } from "three"

/**
 * The set of theme-derived colors exposed to R3F scenes.
 * Every value is a live `THREE.Color`, ready to assign to a material.
 */
export type ShadcnThemeColors = {
  primaryColor: Color
  primaryForegroundColor: Color
  bgColor: Color
  foregroundColor: Color
  borderColor: Color
  mutedColor: Color
  accentColor: Color
  isDark: boolean
}

/** CSS custom properties read from `:root` / `.dark`. */
const CSS_VARS = {
  primaryColor: "--primary",
  primaryForegroundColor: "--primary-foreground",
  bgColor: "--background",
  foregroundColor: "--foreground",
  borderColor: "--border",
  mutedColor: "--muted-foreground",
  accentColor: "--accent",
} as const

/**
 * Neutral SSR defaults (a dark-ish indigo palette) used before the DOM is
 * available, so the first paint is never a flash of pure black.
 */
function createDefaults(): ShadcnThemeColors {
  return {
    primaryColor: new Color().setHSL(263 / 360, 0.7, 0.6),
    primaryForegroundColor: new Color().setHSL(0, 0, 0.98),
    bgColor: new Color().setHSL(240 / 360, 0.1, 0.039),
    foregroundColor: new Color().setHSL(0, 0, 0.98),
    borderColor: new Color().setHSL(240 / 360, 0.037, 0.159),
    mutedColor: new Color().setHSL(240 / 360, 0.05, 0.649),
    accentColor: new Color().setHSL(240 / 360, 0.037, 0.159),
    isDark: true,
  }
}

/** Matches a bare shadcn HSL triplet, e.g. "263 70% 50%" (optional "/ alpha"). */
const HSL_TRIPLET = /^-?[\d.]+\s+-?[\d.]+%\s+-?[\d.]+%(\s*\/\s*[\d.]+%?)?$/

/**
 * A lazily-created 1x1 canvas 2D context used to normalise arbitrary CSS
 * colors. The browser's own color parser resolves ANY format the CSS spec
 * supports — including `oklch()` (Tailwind v4 / recent shadcn), `color()`,
 * `hsl()`, `rgb()`, hex and named colors — and serialises the result back as
 * `#rrggbb` / `rgba(...)`, which `THREE.Color.setStyle` understands.
 */
let probeCtx: CanvasRenderingContext2D | null | undefined
function getProbeCtx(): CanvasRenderingContext2D | null {
  if (probeCtx !== undefined) return probeCtx
  if (typeof document === "undefined") return (probeCtx = null)
  probeCtx = document.createElement("canvas").getContext("2d")
  return probeCtx
}

/**
 * Normalise any CSS color string to a form THREE can parse (or `null` if the
 * browser rejects it). Assigning an invalid color to `fillStyle` is a no-op, so
 * we probe with two different sentinels: if the result differs, the input was
 * valid and both reflect its normalised value; if it "sticks" to a sentinel,
 * the input was invalid.
 */
function normalizeColor(input: string): string | null {
  const ctx = getProbeCtx()
  if (!ctx) return null
  ctx.fillStyle = "#000"
  ctx.fillStyle = input
  const a = ctx.fillStyle
  ctx.fillStyle = "#fff"
  ctx.fillStyle = input
  const b = ctx.fillStyle
  return a === b ? a : null
}

/**
 * Parse a raw CSS color (as read from a custom property) into a `THREE.Color`.
 *
 * shadcn ships colors as bare HSL triplets ("263 70% 50%"), which we wrap in
 * `hsl(...)`. Everything else — `oklch()`, `rgb()`, `#hex`, `hsl()`, named — is
 * handed to the browser's parser via a canvas probe, so modern oklch-based
 * themes resolve correctly instead of silently falling back to the default.
 */
function parseColor(raw: string, target: Color): Color {
  const value = raw.trim()
  if (!value) return target

  const candidate = HSL_TRIPLET.test(value) ? `hsl(${value})` : value

  // Preferred path: let the browser resolve any CSS color format.
  const normalized = normalizeColor(candidate)
  if (normalized) {
    try {
      return target.setStyle(normalized)
    } catch {
      /* fall through to the manual paths below */
    }
  }

  // Fallbacks for environments without a canvas (e.g. exotic SSR shims).
  const triplet = value.match(/^(-?[\d.]+)\s+(-?[\d.]+)%\s+(-?[\d.]+)%/)
  if (triplet) {
    const h = parseFloat(triplet[1]) / 360
    const s = parseFloat(triplet[2]) / 100
    const l = parseFloat(triplet[3]) / 100
    return target.setHSL(h, s, l)
  }
  try {
    return target.setStyle(value)
  } catch {
    return target
  }
}

function readColors(previous?: ShadcnThemeColors): ShadcnThemeColors {
  if (typeof window === "undefined" || typeof document === "undefined") {
    return previous ?? createDefaults()
  }

  const root = document.documentElement
  const styles = getComputedStyle(root)
  const base = previous ?? createDefaults()

  const next: ShadcnThemeColors = {
    ...base,
    primaryColor: parseColor(
      styles.getPropertyValue(CSS_VARS.primaryColor),
      base.primaryColor.clone()
    ),
    primaryForegroundColor: parseColor(
      styles.getPropertyValue(CSS_VARS.primaryForegroundColor),
      base.primaryForegroundColor.clone()
    ),
    bgColor: parseColor(
      styles.getPropertyValue(CSS_VARS.bgColor),
      base.bgColor.clone()
    ),
    foregroundColor: parseColor(
      styles.getPropertyValue(CSS_VARS.foregroundColor),
      base.foregroundColor.clone()
    ),
    borderColor: parseColor(
      styles.getPropertyValue(CSS_VARS.borderColor),
      base.borderColor.clone()
    ),
    mutedColor: parseColor(
      styles.getPropertyValue(CSS_VARS.mutedColor),
      base.mutedColor.clone()
    ),
    accentColor: parseColor(
      styles.getPropertyValue(CSS_VARS.accentColor),
      base.accentColor.clone()
    ),
    isDark: root.classList.contains("dark"),
  }

  return next
}

export type ThemeMode = "auto" | "light" | "dark"

/**
 * Bridges your shadcn/ui CSS variables into Three.js.
 *
 * - Reads `--primary`, `--background`, `--border`, ... on mount.
 * - Watches `<html class>` with a MutationObserver and re-reads on dark-mode
 *   toggles (works with next-themes, which flips the `.dark` class).
 * - SSR-safe: returns neutral defaults until the DOM is ready.
 *
 * @param mode `"auto"` (default) tracks the document theme. `"light"` / `"dark"`
 *   force a palette by temporarily toggling a detached probe element.
 */
export function useShadcnTheme(mode: ThemeMode = "auto"): ShadcnThemeColors {
  const [colors, setColors] = React.useState<ShadcnThemeColors>(() =>
    createDefaults()
  )

  React.useEffect(() => {
    if (typeof window === "undefined") return

    // These reads pull the live values out of an external system (the DOM's
    // computed CSS variables), which is a legitimate effect → state sync.
    if (mode !== "auto") {
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setColors((prev) => readForcedMode(mode, prev))
      return
    }

    // Initial read once mounted (handles hydration + system theme).
    setColors((prev) => readColors(prev))

    const root = document.documentElement
    const observer = new MutationObserver(() => {
      setColors((prev) => readColors(prev))
    })
    observer.observe(root, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    })

    return () => observer.disconnect()
  }, [mode])

  return colors
}

/**
 * Read the palette as it would appear under a forced light/dark mode.
 * We synchronously toggle the `.dark` class on `<html>`, read the computed
 * variables, then restore the original class in the same synchronous block.
 * getComputedStyle forces a style flush, so the values are accurate and the
 * browser never paints the intermediate state (no flicker).
 */
function readForcedMode(
  mode: "light" | "dark",
  previous: ShadcnThemeColors
): ShadcnThemeColors {
  if (typeof document === "undefined") return previous

  const root = document.documentElement
  const wasDark = root.classList.contains("dark")
  const wantDark = mode === "dark"

  if (wantDark !== wasDark) root.classList.toggle("dark", wantDark)
  const result = readColors(previous)
  if (wantDark !== wasDark) root.classList.toggle("dark", wasDark)

  return result
}

More from threecn

Browse all components in this library →