shadimage
threecn
uiclothsource: ok · refreshed

Cloth

A real spring-mass cloth (Verlet) hanging from its top edge and rippling under procedural wind, with the theme woven --primary to --accent.

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

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

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

Files3 · source cached 10/7/2026, 11:30:34 PM

components/threecn/cloth.tsx
"use client"

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

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

const WIDTH = 4.2
const HEIGHT = 3
const DAMPING = 0.97
const ITERATIONS = 4

type ClothSim = {
  positions: Float32Array
  colors: Float32Array
  indices: Uint32Array
  pinned: Uint8Array
  homeX: Float32Array
  homeY: Float32Array
  cA: Uint32Array
  cB: Uint32Array
  cRest: Float32Array
  cols: number
  rows: number
}

function buildCloth(segments: number): ClothSim {
  const cols = segments + 1
  const rows = segments + 1
  const n = cols * rows
  const positions = new Float32Array(n * 3)
  const colors = new Float32Array(n * 3)
  const pinned = new Uint8Array(n)
  const homeX = new Float32Array(n)
  const homeY = new Float32Array(n)

  for (let v = 0; v < rows; v++) {
    for (let u = 0; u < cols; u++) {
      const i = v * cols + u
      const x = (u / segments - 0.5) * WIDTH
      const y = (0.5 - v / segments) * HEIGHT
      positions[i * 3] = x
      positions[i * 3 + 1] = y
      positions[i * 3 + 2] = 0
      homeX[i] = x
      homeY[i] = y
      if (v === 0) pinned[i] = 1 // top row hangs the cloth
    }
  }

  // Structural constraints: each particle links to its right and lower neighbor.
  const restX = WIDTH / segments
  const restY = HEIGHT / segments
  const cA: number[] = []
  const cB: number[] = []
  const cRest: number[] = []
  for (let v = 0; v < rows; v++) {
    for (let u = 0; u < cols; u++) {
      const i = v * cols + u
      if (u < cols - 1) {
        cA.push(i)
        cB.push(i + 1)
        cRest.push(restX)
      }
      if (v < rows - 1) {
        cA.push(i)
        cB.push(i + cols)
        cRest.push(restY)
      }
    }
  }

  // Two triangles per cell.
  const indices = new Uint32Array(segments * segments * 6)
  let o = 0
  for (let v = 0; v < segments; v++) {
    for (let u = 0; u < segments; u++) {
      const a = v * cols + u
      const b = a + 1
      const c = a + cols
      const d = c + 1
      indices[o++] = a
      indices[o++] = c
      indices[o++] = b
      indices[o++] = b
      indices[o++] = c
      indices[o++] = d
    }
  }

  return {
    positions,
    colors,
    indices,
    pinned,
    homeX,
    homeY,
    cA: Uint32Array.from(cA),
    cB: Uint32Array.from(cB),
    cRest: Float32Array.from(cRest),
    cols,
    rows,
  }
}

function Drape({
  segments,
  speed,
  wind,
  theme,
}: {
  segments: number
  speed: number
  wind: number
  theme: ThemeMode
}) {
  const { primaryColor, accentColor } = useShadcnTheme(theme)
  const geoRef = React.useRef<THREE.BufferGeometry>(null)

  const sim = React.useMemo(() => buildCloth(segments), [segments])

  // Verlet needs each particle's previous position. It's mutated every frame,
  // so it lives in a ref (never read during render) rather than in the memo.
  const prevRef = React.useRef<Float32Array | null>(null)
  React.useLayoutEffect(() => {
    prevRef.current = sim.positions.slice()
  }, [sim])

  // Weave the theme gradient into the fabric, top → bottom.
  React.useLayoutEffect(() => {
    const attr = geoRef.current?.getAttribute("color") as
      | THREE.BufferAttribute
      | undefined
    if (!attr) return
    const arr = attr.array as Float32Array
    const { cols, rows } = sim
    const c = new THREE.Color()
    for (let v = 0; v < rows; v++) {
      c.copy(primaryColor).lerp(accentColor, v / (rows - 1))
      for (let u = 0; u < cols; u++) {
        const i = (v * cols + u) * 3
        arr[i] = c.r
        arr[i + 1] = c.g
        arr[i + 2] = c.b
      }
    }
    attr.needsUpdate = true
  }, [sim, primaryColor, accentColor])

  useFrame((state, delta) => {
    const geo = geoRef.current
    if (!geo) return
    const prev = prevRef.current
    if (!prev) return
    const posAttr = geo.getAttribute("position") as THREE.BufferAttribute
    const positions = posAttr.array as Float32Array
    const t = state.clock.getElapsedTime()
    const dt = Math.min(delta, 0.033)
    const dt2 = dt * dt
    const { pinned, homeX, homeY, cA, cB, cRest, cols } = sim

    // Wind gusts, stronger toward the free bottom edge.
    const windZ = (Math.sin(t * 1.3) * 0.5 + 0.6) * 9 * wind * speed
    const windX = Math.sin(t * 0.7) * 2 * wind * speed
    const gravity = -3.2

    // Pin the top row, letting it sway so the whole sheet ripples.
    for (let u = 0; u < cols; u++) {
      const i = u
      const io = i * 3
      positions[io] = homeX[i] + Math.sin(t * 0.6 + u * 0.35) * 0.12
      positions[io + 1] = homeY[i]
      positions[io + 2] = Math.sin(t * 0.9 + u * 0.5) * 0.35
    }

    // Verlet integrate the free particles.
    for (let i = cols; i < pinned.length; i++) {
      if (pinned[i]) continue
      const io = i * 3
      const gust = Math.sin(t * 2 + positions[io + 1] * 0.8)
      const ax = windX * (0.6 + 0.4 * gust)
      const ay = gravity
      const az = windZ * (0.5 + 0.5 * gust)

      const px = positions[io]
      const py = positions[io + 1]
      const pz = positions[io + 2]
      positions[io] = px + (px - prev[io]) * DAMPING + ax * dt2
      positions[io + 1] = py + (py - prev[io + 1]) * DAMPING + ay * dt2
      positions[io + 2] = pz + (pz - prev[io + 2]) * DAMPING + az * dt2
      prev[io] = px
      prev[io + 1] = py
      prev[io + 2] = pz
    }

    // Relax the distance constraints a few times per frame.
    for (let iter = 0; iter < ITERATIONS; iter++) {
      for (let k = 0; k < cRest.length; k++) {
        const a = cA[k]
        const b = cB[k]
        const ao = a * 3
        const bo = b * 3
        const dx = positions[bo] - positions[ao]
        const dy = positions[bo + 1] - positions[ao + 1]
        const dz = positions[bo + 2] - positions[ao + 2]
        const dist = Math.hypot(dx, dy, dz) || 1e-6
        const diff = (dist - cRest[k]) / dist
        const pa = pinned[a]
        const pb = pinned[b]
        if (pa && pb) continue
        const w = pa || pb ? 1 : 0.5
        const mx = dx * diff * w
        const my = dy * diff * w
        const mz = dz * diff * w
        if (!pa) {
          positions[ao] += mx
          positions[ao + 1] += my
          positions[ao + 2] += mz
        }
        if (!pb) {
          positions[bo] -= mx
          positions[bo + 1] -= my
          positions[bo + 2] -= mz
        }
      }
    }

    ;(geo.getAttribute("position") as THREE.BufferAttribute).needsUpdate = true
    geo.computeVertexNormals()
  })

  return (
    <mesh key={segments}>
      <bufferGeometry ref={geoRef}>
        <bufferAttribute attach="attributes-position" args={[sim.positions, 3]} />
        <bufferAttribute attach="attributes-color" args={[sim.colors, 3]} />
        <bufferAttribute attach="index" args={[sim.indices, 1]} />
      </bufferGeometry>
      <meshStandardMaterial
        vertexColors
        side={THREE.DoubleSide}
        roughness={0.42}
        metalness={0.16}
      />
    </mesh>
  )
}

export type ClothProps = {
  /** Grid resolution per side (more = finer fabric, heavier sim). */
  segments?: number
  /** Overall motion speed multiplier. */
  speed?: number
  /** Wind strength. */
  wind?: number
  className?: string
  theme?: ThemeMode
  environment?: SceneContainerProps["environment"]
}

/**
 * A real spring-mass cloth (Verlet integration) hanging from its top edge and
 * rippling under procedural wind. The theme gradient is woven top-to-bottom,
 * `--primary` → `--accent`, and the surface catches the scene lighting.
 */
export function Cloth({
  segments = 26,
  speed = 1,
  wind = 1,
  className,
  theme = "auto",
  environment = "studio",
}: ClothProps) {
  return (
    <SceneContainer
      className={className}
      theme={theme}
      environment={environment}
      camera={[0, 0, 6.5]}
      fov={42}
    >
      <Drape segments={segments} speed={speed} wind={wind} 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 →